'use client'; import { useState } from 'react'; import { computeAvailableSlots, getBusinessHoursForDate, type DateAvailabilityConstraints, } from 'brainerce'; import { cn } from '@/core/lib/utils'; import { DatePicker } from '@/components/checkout/date-picker'; /** * Date + time control for checkout `DATETIME` custom fields. * * **The value it emits is `"YYYY-MM-DDTHH:mm"` with no UTC offset**, which the * API resolves in the *store's* timezone — the only frame a delivery slot has. * Do not append an offset from the shopper's browser (a buyer travelling * abroad would book a different hour than the one they tapped), and never * concatenate a human slot label: `"2026-08-13" + "T13:00-14:00"` parses as a * -14:00 UTC offset and silently books the following day. * * Which times exist comes from the SDK, not from a list hardcoded here: * - `computeAvailableSlots` → discrete slot starts, when the field defines * `slotDurationMinutes`. The submitted time must equal one of them exactly. * - `getBusinessHoursForDate` → the open/close windows, when it doesn't. Slots * being empty does NOT mean the day is closed; that distinction is why this * component asks for the windows separately. * * Three shapes the naive version of this component gets wrong: * - A slot-based field whose slots have all been overtaken by `leadTimeMinutes` * returns `[]` on an OPEN day. Falling through to the free time input there * would accept any time and 400 on every one of them, so the two cases are * split on `slotDurationMinutes` rather than on the slot count. * - A weekday may carry SEVERAL windows (mornings and evenings). One input * bounded by `windows[0]` would silently hide the second, so every window is * rendered. * - The relative bounds need `timezone` passed through, or the SDK skips them. * * Dependency-free from any design pack, same as `date-picker.tsx`. */ interface DateTimePickerProps { id?: string; /** 'YYYY-MM-DDTHH:mm' or ''. */ value: string; onChange: (value: string) => void; required?: boolean; availability?: DateAvailabilityConstraints | null; /** The store's IANA timezone, from `getStoreInfo().timezone`. Never the browser's. */ timezone?: string; placeholder: string; todayLabel: string; clearLabel: string; prevMonthLabel: string; nextMonthLabel: string; timeLabel: string; closedLabel: string; /** Shown when the day is open but every slot on it has already gone past. */ noTimesLabel: string; /** Joins the two ends of a window when a day trades more than one. */ toLabel: string; className?: string; } function splitValue(value: string): { date: string; time: string } { const [date = '', time = ''] = value.split('T'); return { date, time: time.slice(0, 5) }; } export function DateTimePicker({ id, value, onChange, required, availability, timezone, placeholder, todayLabel, clearLabel, prevMonthLabel, nextMonthLabel, timeLabel, closedLabel, noTimesLabel, toLabel, className, }: DateTimePickerProps) { const submitted = splitValue(value); // The date survives on its own while no time is picked yet. The field value // stays empty until both halves exist, so a `required` field can't be // satisfied by a half-filled answer. const [date, setDate] = useState(submitted.date); // No `now`: the SDK reads the real clock per call, so a page left open past // the cutoff closes the day rather than failing at submit. const clock = timezone ? { timezone } : undefined; const slotBased = !!availability?.slotDurationMinutes; const slots = date ? computeAvailableSlots(availability, date, clock) : []; const windows = date ? getBusinessHoursForDate(availability, date, clock) : []; // Grey out a day with nothing left on it. For a slot-based field that means // no slots survive, which a windows-only check would miss on the lead-time // boundary day and leave the shopper on a date with no times to pick. const openDay = (dateYYYYMMDD: string) => slotBased ? computeAvailableSlots(availability, dateYYYYMMDD, clock).length > 0 : !availability?.businessHours?.length || getBusinessHoursForDate(availability, dateYYYYMMDD, clock).length > 0; const commit = (nextDate: string, nextTime: string) => { setDate(nextDate); onChange(nextDate && nextTime ? `${nextDate}T${nextTime}` : ''); }; return (
{noTimesLabel}
)} {date && !slotBased && windows.length > 0 && ({closedLabel}
)}