"use client"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import Image from "next/image"; import { createPortal } from "react-dom"; export type ProductFormModalProps = { open: boolean; mode: "add" | "edit"; onClose: () => void; onSubmit: (values: ProductFormValues) => Promise<{ success: boolean; error?: string }>; /** Defaults / initial values (edit mode passes the current product) */ initial?: Partial; /** List of selectable brands (only shown to platform admins) */ brands?: { id: string; name: string }[]; /** When true the brand picker is hidden and the admin's brand is used */ lockBrand?: boolean; /** Hard-locked brand id (for brand_admin / store_employee) */ lockedBrandId?: string; /** Pre-uploaded image URL (edit mode) */ initialImageUrl?: string | null; /** Server action: upload a file and return the public URL */ onUploadImage: (file: File) => Promise<{ success: boolean; imageUrl?: string; error?: string }>; }; export type ProductFormValues = { name: string; description: string; price: string; type: "pickup" | "wholesale" | "subscription"; brand_id: string; active: boolean; is_taxable: boolean; image_url: string | null; available_from?: string | null; available_until?: string | null; }; const TYPE_OPTIONS: Array<{ value: ProductFormValues["type"]; label: string; italic: string; icon: React.ReactNode; }> = [ { value: "pickup", label: "Pickup", italic: "Farm & stops", icon: ( ), }, { value: "wholesale", label: "Wholesale", italic: "B2B portal", icon: ( ), }, { value: "subscription", label: "Subscription", italic: "Recurring", icon: ( ), }, ]; export default function ProductFormModal({ open, mode, onClose, onSubmit, initial, brands = [], lockBrand = false, lockedBrandId = "", initialImageUrl = null, onUploadImage, }: ProductFormModalProps) { // Form state — lazy initializers read the latest `initial` / `initialImageUrl` // props. The parent (ProductsClient) supplies a `key` derived from // `editingProduct?.id` so changing the product under edit triggers a // remount instead of a state-reset effect. const [name, setName] = useState(() => initial?.name ?? ""); const [description, setDescription] = useState(() => initial?.description ?? ""); const [price, setPrice] = useState(() => initial?.price ?? ""); const [type, setType] = useState( () => (initial?.type as ProductFormValues["type"]) ?? "pickup" ); const [brandId, setBrandId] = useState( () => initial?.brand_id ?? lockedBrandId ?? brands[0]?.id ?? "" ); const [active, setActive] = useState(() => initial?.active ?? true); const [isTaxable, setIsTaxable] = useState(() => initial?.is_taxable ?? true); const [availableFrom, setAvailableFrom] = useState( () => (initial?.available_from ? initial.available_from.split("T")[0] : "") ); const [availableUntil, setAvailableUntil] = useState( () => (initial?.available_until ? initial.available_until.split("T")[0] : "") ); // Image state const [imagePreview, setImagePreview] = useState(() => initialImageUrl ?? null); const [imageUrl, setImageUrl] = useState(() => initialImageUrl ?? null); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [isDrag, setIsDrag] = useState(false); const fileInputRef = useRef(null); // Submit state const [saving, setSaving] = useState(false); const [error, setError] = useState(null); // SSR-safe mounted flag via useSyncExternalStore so the first render // already reflects "we are on the client" — no flash of an empty // portal, no `useEffect(() => setMounted(true), [])` to flag. const mounted = useSyncExternalStore( () => () => {}, () => true, () => false, ); // Body scroll lock + escape key // Native handles both focus trapping and ESC; we just open // it with showModal() and forward the cancel event to onClose so the // parent's React state stays in sync with the native close behavior. const dialogRef = useRef(null); useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; if (!dialog.open) dialog.showModal(); const onCancel = (e: Event) => { e.preventDefault(); onClose(); }; dialog.addEventListener("cancel", onCancel); return () => { dialog.removeEventListener("cancel", onCancel); }; }, [open, onClose]); const handleFile = useCallback( async (file: File) => { const validTypes = ["image/png", "image/jpeg", "image/webp"]; if (!validTypes.includes(file.type)) { setUploadError("PNG, JPEG, or WebP only."); return; } if (file.size > 5 * 1024 * 1024) { setUploadError("File must be under 5MB."); return; } setUploadError(null); setUploading(true); // Local preview first const localUrl = URL.createObjectURL(file); setImagePreview(localUrl); const result = await onUploadImage(file); setUploading(false); if (result.success && result.imageUrl) { setImageUrl(result.imageUrl); setImagePreview(result.imageUrl); URL.revokeObjectURL(localUrl); } else { setUploadError(result.error ?? "Upload failed."); setImagePreview(imageUrl); // revert } }, [onUploadImage, imageUrl] ); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); if (!name.trim()) { setError("Product name is required."); return; } if (!price || isNaN(parseFloat(price)) || parseFloat(price) < 0) { setError("Valid price is required."); return; } if (!lockBrand && !brandId) { setError("Please select a brand."); return; } setSaving(true); const res = await onSubmit({ name: name.trim(), description: description.trim(), price, type, brand_id: lockBrand ? lockedBrandId : brandId, active, is_taxable: isTaxable, image_url: imageUrl, available_from: availableFrom ? new Date(availableFrom).toISOString() : null, available_until: availableUntil ? new Date(availableUntil).toISOString() : null, }); setSaving(false); if (!res.success) { setError(res.error ?? "Something went wrong."); return; } onClose(); } if (!mounted || !open) return null; const showBrandPicker = !lockBrand; const lockedBrandName = brands.find((b) => b.id === lockedBrandId)?.name ?? lockedBrandId; return createPortal(
{/* grain overlay */}
{/* HEADER */}
{mode === "add" ? "Nouveau" : "Mise à jour"} · MMXXVI

{mode === "add" ? ( <> Add a product ) : ( <> Edit {initial?.name || "product"} )}

{mode === "add" ? "Compose a new entry for the harvest catalog — every detail, like a recipe." : "Refine the details of this entry. The catalog is a living record."}


{/* BODY — two columns */}
{ e.preventDefault(); setIsDrag(true); }} >
{/* LEFT — Image hero */}
01 · Media
{ if (e.key === "Enter" || e.key === " ") { if (!uploading) fileInputRef.current?.click(); } }} onDragOver={(e) => { e.preventDefault(); setIsDrag(true); }} onDragLeave={(e) => { if (e.currentTarget === e.target) setIsDrag(false); }} onDrop={(e) => { e.preventDefault(); setIsDrag(false); const file = e.dataTransfer.files?.[0]; if (file) handleFile(file); }} onClick={() => !uploading && fileInputRef.current?.click()} className={[ "atelier-drop", isDrag ? "is-drag" : "", uploadError ? "is-error" : "", imagePreview ? "has-image" : "", ].join(" ")} > {/* status pill */}
{imagePreview ? uploading ? "Uploading" : "Image set" : "No image yet"}
{uploading ? (

Pouring into the cellar…

) : imagePreview ? (
Product preview
) : ( <> {/* ornamental drop zone content */}

Drag & drop

or click to choose
a harvest portrait

PNG · JPEG · WebP · 5MB max

)} { const file = e.target.files?.[0]; if (file) handleFile(file); e.target.value = ""; }} />
{uploadError && (

{uploadError}

)}
{/* RIGHT — Form fields */}
{error && (
{error}
)} {/* 02 IDENTITY */}
02 · Identity
setName(e.target.value)} placeholder="e.g. Dozen Sweet Corn" className="atelier-input atelier-input--display" autoComplete="off" />