profit-planet-frontend/src/app/admin/subscriptions/createSubscription/page.tsx

415 lines
19 KiB
TypeScript

"use client";
import React, { useEffect, useMemo, useState } from 'react';
import PageLayout from '../../../components/PageLayout';
import useCoffeeManagement from '../hooks/useCoffeeManagement';
import { PhotoIcon } from '@heroicons/react/24/solid';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import ImageCropModal from '../components/ImageCropModal';
import { useTranslation } from '../../../i18n/useTranslation';
export default function CreateSubscriptionPage() {
const { t } = useTranslation();
const { createProduct } = useCoffeeManagement();
const router = useRouter();
const [error, setError] = useState<string | null>(null);
// form state
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [price, setPrice] = useState('0.00');
const [state, setState] = useState<'available'|'unavailable'>('available');
const [pictureFile, setPictureFile] = useState<File | undefined>(undefined);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
const [showCropModal, setShowCropModal] = useState(false);
const [currency, setCurrency] = useState('EUR');
const [isFeatured, setIsFeatured] = useState(false);
// Gallery images (multi-upload, no crop)
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
// Fixed billing defaults (locked: month / 1)
const billingInterval: 'month' = 'month';
const intervalCount: number = 1;
const onCreate = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
await createProduct({
title,
description,
price: parseFloat(price),
currency,
is_featured: isFeatured,
state: state === 'available',
pictureFile,
pictureFiles: galleryFiles.length ? galleryFiles : undefined,
});
router.push('/admin/subscriptions');
} catch (e: any) {
setError(e.message || 'Failed to create');
}
};
// Cleanup object URLs
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
if (originalImageSrc) URL.revokeObjectURL(originalImageSrc);
galleryPreviews.forEach(u => URL.revokeObjectURL(u));
};
}, [previewUrl, originalImageSrc, galleryPreviews]);
function handleSelectFile(file?: File) {
if (!file) return;
const allowed = ['image/jpeg','image/png','image/webp'];
if (!allowed.includes(file.type)) {
setError('Invalid image type. Allowed: JPG, PNG, WebP');
return;
}
if (file.size > 10 * 1024 * 1024) { // 10MB
setError('Image exceeds 10MB limit');
return;
}
setError(null);
if (originalImageSrc) URL.revokeObjectURL(originalImageSrc);
// Create object URL for cropping
const url = URL.createObjectURL(file);
setOriginalImageSrc(url);
setShowCropModal(true);
}
function handleCropComplete(croppedBlob: Blob) {
// Convert blob to file
const croppedFile = new File([croppedBlob], 'cropped-image.jpg', { type: 'image/jpeg' });
setPictureFile(croppedFile);
// Create preview URL
if (previewUrl) URL.revokeObjectURL(previewUrl);
const url = URL.createObjectURL(croppedBlob);
setPreviewUrl(url);
}
function handleAddGalleryFiles(files: FileList | null) {
if (!files || files.length === 0) return;
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
const newFiles: File[] = [];
const newPreviews: string[] = [];
for (const file of Array.from(files)) {
if (!allowed.includes(file.type)) {
setError(`"${file.name}" is not a valid image type (JPG, PNG, WebP only).`);
continue;
}
if (file.size > 10 * 1024 * 1024) {
setError(`"${file.name}" exceeds the 10MB limit.`);
continue;
}
newFiles.push(file);
newPreviews.push(URL.createObjectURL(file));
}
setGalleryFiles(prev => [...prev, ...newFiles]);
setGalleryPreviews(prev => [...prev, ...newPreviews]);
}
function handleRemoveGalleryImage(index: number) {
setGalleryPreviews(prev => {
URL.revokeObjectURL(prev[index]);
return prev.filter((_, i) => i !== index);
});
setGalleryFiles(prev => prev.filter((_, i) => i !== index));
}
function handleSetThumbnailFromGallery(index: number) {
const source = galleryFiles[index];
if (!source) return;
setError(null);
const thumbFile = new File([source], source.name, { type: source.type });
setPictureFile(thumbFile);
if (previewUrl) URL.revokeObjectURL(previewUrl);
const thumbPreview = URL.createObjectURL(source);
setPreviewUrl(thumbPreview);
}
return (
<PageLayout contentClassName="flex-1 relative w-full">
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
{/* Header card */}
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">{t('autofix.kaa30f0cd')}</h1>
<p className="mt-1 text-sm text-slate-500">{t('autofix.kf72d41db')}</p>
</div>
<Link
href="/admin/subscriptions"
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 transition"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{t('autofix.kd8a5ad17')}
</Link>
</div>
{/* Form card */}
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<form onSubmit={onCreate} className="space-y-8">
{/* Thumbnail */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-1">Thumbnail</label>
<p className="text-xs text-slate-500 mb-3">Single image used as the card thumbnail. You can crop it after selecting (16:9, sent as <code className="rounded bg-slate-100 px-1 py-0.5 text-slate-600">picture</code>).</p>
<div
className="relative flex justify-center items-center rounded-2xl border-2 border-dashed border-slate-200 bg-slate-50 cursor-pointer overflow-hidden transition hover:border-slate-400 hover:bg-slate-100"
style={{ minHeight: '400px' }}
onClick={() => document.getElementById('file-upload')?.click()}
onDragOver={e => e.preventDefault()}
onDrop={e => {
e.preventDefault();
if (e.dataTransfer.files?.[0]) handleSelectFile(e.dataTransfer.files[0]);
}}
>
{!previewUrl && (
<div className="text-center w-full px-6 py-10">
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-slate-300" />
<div className="mt-4 text-base font-medium text-slate-600">
<span>{t('autofix.k6ee0a1b6')}</span>
</div>
<p className="text-sm text-slate-500 mt-2">{t('autofix.k80ac9651')}</p>
<p className="text-xs text-slate-400 mt-2">{t('autofix.k41ab9eb6')}</p>
</div>
)}
{previewUrl && (
<div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-slate-100 p-6">
<img
src={previewUrl}
alt="Preview"
className="max-h-[380px] max-w-full object-contain rounded-xl shadow-lg"
/>
<div className="absolute top-4 right-4 flex gap-2">
<button
type="button"
onClick={e => { e.stopPropagation(); setShowCropModal(true); }}
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-semibold text-slate-800 shadow hover:bg-white transition"
>{t('autofix.k73d1d7d7')}</button>
<button
type="button"
onClick={e => { e.stopPropagation(); setPictureFile(undefined); setPreviewUrl(null); }}
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-semibold text-rose-600 shadow hover:bg-white transition"
>Remove</button>
</div>
</div>
)}
<input
id="file-upload"
name="file-upload"
type="file"
accept="image/*"
className="hidden"
onChange={e => handleSelectFile(e.target.files?.[0])}
/>
</div>
</div>
{/* Gallery Images */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-1">{t('autofix.ka219f1d9')}</label>
<p className="text-xs text-slate-500 mb-3">
Upload additional product images (JPG, PNG, WebP · max 10 MB each). These are sent as <code className="rounded bg-slate-100 px-1 py-0.5 text-slate-600">pictures</code>{t('autofix.k2992fa62')}<code className="rounded bg-slate-100 px-1 py-0.5 text-slate-600">pictureUrls</code>.
</p>
{/* Gallery grid */}
{galleryPreviews.length > 0 && (
<div className="mb-3 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-3">
{galleryPreviews.map((url, i) => (
<div key={i} className="relative group rounded-xl overflow-hidden border border-slate-200 bg-slate-50 aspect-video">
<img src={url} alt={`Gallery ${i + 1}`} className="w-full h-full object-cover" />
<button
type="button"
onClick={() => handleSetThumbnailFromGallery(i)}
className="absolute top-1 right-1 rounded-md bg-white/95 px-2 py-1 text-[10px] font-semibold text-slate-700 shadow hover:bg-white transition"
>{t('autofix.k6c1bb40b')}</button>
<button
type="button"
onClick={() => handleRemoveGalleryImage(i)}
className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition"
aria-label="Remove image"
>
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<span className="absolute bottom-1 left-1 rounded bg-black/50 px-1.5 py-0.5 text-[10px] text-white font-medium">#{i + 1}</span>
</div>
))}
</div>
)}
{/* Add images button */}
<label
htmlFor="gallery-upload"
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-600 hover:border-slate-400 hover:bg-slate-100 transition"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{galleryFiles.length > 0 ? `Add more (${galleryFiles.length} selected)` : 'Add gallery images'}
<input
id="gallery-upload"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="hidden"
onChange={e => handleAddGalleryFiles(e.target.files)}
/>
</label>
</div>
{/* Title */}
<div>
<label htmlFor="title" className="block text-sm font-semibold text-slate-700 mb-1">Title</label>
<input
id="title"
name="title"
required
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
placeholder="Title"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-semibold text-slate-700 mb-1">Description</label>
<textarea
id="description"
name="description"
required
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
rows={3}
placeholder={t('autofix.k3477c83a')}
value={description}
onChange={e => setDescription(e.target.value)}
/>
<p className="mt-1 text-xs text-slate-500">{t('autofix.k0affa826')}</p>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{/* Price */}
<div>
<label htmlFor="price" className="block text-sm font-semibold text-slate-700 mb-1">Price per pack</label>
<input
id="price"
name="price"
required
min={0.01}
step={0.01}
type="number"
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
placeholder="0.00"
value={price}
onChange={e => setPrice(e.target.value)}
onBlur={e => { const n = parseFloat(e.target.value); if (!isNaN(n)) setPrice(n.toFixed(2)); }}
/>
<p className="mt-1 text-xs text-slate-500">Enter the gross price for one pack. The system converts it to the internal per-capsule value automatically.</p>
</div>
{/* Currency */}
<div>
<label htmlFor="currency" className="block text-sm font-semibold text-slate-700 mb-1">Currency (e.g., EUR)</label>
<input
id="currency"
name="currency"
required
maxLength={3}
pattern="[A-Za-z]{3}"
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
placeholder="EUR"
value={currency}
onChange={e => setCurrency(e.target.value.toUpperCase().slice(0, 3))}
/>
</div>
{/* Featured */}
<div className="flex items-center gap-3 mt-2">
<input
id="featured"
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900"
checked={isFeatured}
onChange={e => setIsFeatured(e.target.checked)}
/>
<label htmlFor="featured" className="text-sm font-semibold text-slate-700">Featured</label>
</div>
{/* Billing + Availability */}
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-1">{t('autofix.ka3ee9ded')}</label>
<p className="text-xs text-slate-500 mb-2">Fixed monthly subscription billing (interval count = 1). These settings are locked.</p>
<div className="flex gap-3">
<input disabled value={billingInterval} className="w-40 rounded-xl border border-slate-200 bg-slate-100 px-4 py-3 text-sm text-slate-500" />
<input disabled value={intervalCount} className="w-24 rounded-xl border border-slate-200 bg-slate-100 px-4 py-3 text-sm text-slate-500" />
</div>
</div>
<div>
<label htmlFor="availability" className="block text-sm font-semibold text-slate-700 mb-1">Availability</label>
<select
id="availability"
name="availability"
required
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:ring-2 focus:ring-slate-900 focus:border-transparent"
value={state}
onChange={e => setState(e.target.value as any)}
>
<option value="available">Available</option>
<option value="unavailable">Unavailable</option>
</select>
</div>
</div>
</div>
{error && (
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{error}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-2">
<Link
href="/admin/subscriptions"
className="rounded-xl border border-slate-200 bg-white px-5 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition"
>
Cancel
</Link>
<button
type="submit"
className="inline-flex items-center rounded-xl bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white hover:bg-slate-800 transition"
>
{t('autofix.kaa30f0cd')}
</button>
</div>
</form>
</div>
</div>
{/* Image Crop Modal */}
{originalImageSrc && (
<ImageCropModal
isOpen={showCropModal}
imageSrc={originalImageSrc}
onClose={() => setShowCropModal(false)}
onCropComplete={handleCropComplete}
/>
)}
</PageLayout>
);
}