'use client'; import { useMemo, useState } from 'react'; import type { CheckoutCustomFieldDefinition } from 'brainerce'; import { isDateValueAllowed } from 'brainerce'; import { useTranslations } from '@/core/lib/translations'; import { cn } from '@/core/lib/utils'; import { DatePicker } from '@/components/checkout/date-picker'; import { DateTimePicker } from '@/components/checkout/datetime-picker'; /** Inline, dependency-free — this file lives in the base template and must * not assume any design pack (e.g. Atelier's icon set) is present; canvas * scaffolds skip design packs entirely. */ const INPUT_CLASS = 'h-11 w-full rounded-lg border bg-background px-3.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'; interface CustomFieldsStepProps { fields: CheckoutCustomFieldDefinition[]; values: Record; onChange: (key: string, value: unknown) => void; onApply: () => void; onUploadFile?: (file: File) => Promise<{ url: string; key: string }>; /** * The store's IANA timezone (`storeInfo.timezone`). DATE/DATETIME fields need * it to apply a field's leadTimeMinutes/cutoffTime/maxDaysAhead: the SDK * skips those bounds when no clock reaches it, so the picker would offer days * the API rejects. */ timezone?: string; loading?: boolean; className?: string; } const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB const ACCEPTED_IMAGE_TYPES = 'image/jpeg,image/png,image/webp,image/gif'; export function CustomFieldsStep({ fields, values, onChange, onApply, onUploadFile, timezone, loading = false, className, }: CustomFieldsStepProps) { const t = useTranslations('checkout'); const [uploadingKeys, setUploadingKeys] = useState>(new Set()); const isMissing = fields.some((f) => { if (!f.required) return false; const v = values[f.key]; return v === undefined || v === null || v === ''; }); // ---- Pre-submit date guard ---- // // The pickers disable unavailable days and times at the moment they render, // but a shopper can sit on this step long enough for a `leadTimeMinutes` or // `cutoffTime` bound to overtake the value they already picked. Submitting // it then 400s AFTER they have committed. `isDateValueAllowed` is the // authoritative client-side re-check, so run it on every keystroke-free // render and keep the button shut while anything is stale. // // The clock is what applies leadTimeMinutes / cutoffTime / maxDaysAhead, and // `isDateValueAllowed` requires a real IANA `timezone` (it is typed // `string`, not optional). `timezone` on this component IS optional, so when // the store has none we skip the guard entirely rather than pass an empty // string, which would evaluate the bounds against a bogus zone. const staleDateKeys = useMemo(() => { const stale = new Set(); if (!timezone) return stale; for (const field of fields) { if (field.type !== 'DATE' && field.type !== 'DATETIME') continue; if (!field.dateAvailability) continue; const raw = values[field.key]; if (typeof raw !== 'string' || raw === '') continue; // A DATE value is "YYYY-MM-DD", which parses to UTC midnight — exactly // what isDateValueAllowed reads back with getUTC* for the DATE branch. const instant = new Date(raw); if (Number.isNaN(instant.getTime())) continue; const verdict = isDateValueAllowed(instant, field.dateAvailability, field.type, timezone); if (!verdict.allowed) { stale.add(field.key); // `reason` is untranslated English prose meant for developers — log // it, never render it. The shopper gets the localized line below. console.warn(`Checkout field "${field.key}" is no longer available:`, verdict.reason); } } return stale; }, [fields, values, timezone]); return (

{t('customFieldsSubtitle')}

{fields.map((field) => { const value = values[field.key]; const labelEl = ( ); const helpEl = field.description ? (

{field.description}

) : null; switch (field.type) { case 'TEXT': return (
{labelEl} onChange(field.key, e.target.value)} required={field.required} minLength={field.minLength ?? undefined} maxLength={field.maxLength ?? undefined} className={INPUT_CLASS} /> {helpEl}
); case 'TEXTAREA': return (
{labelEl}