'use client'; import { useEffect, useRef, useState } from 'react'; import { isCalendarDateAllowed, type DateAvailabilityConstraints } from 'brainerce'; import { cn } from '@/core/lib/utils'; /** * Custom calendar dropdown for checkout DATE custom fields — replaces the * native ``, whose OS/browser-rendered popup can't be * restyled with CSS in any browser (only the closed field can be). * * Lives next to `custom-fields-step.tsx` (not under `src/ui/`) and stays * dependency-free from any design pack for the same reason that file does: * canvas scaffolds wipe `src/ui` and ship no pack, but never touch * `src/components/`, so this must work without either. * * Day availability is delegated wholesale to the SDK's `isCalendarDateAllowed` * — min/max date, blocked weekdays AND blocked specific dates in one call, the * exact predicate the server re-runs on submit. Reimplementing any part of it * here is how a picker ends up offering a day the API then rejects with 400. * * `timezone` is the other half of that predicate. The field's relative bounds * (`leadTimeMinutes`, `cutoffTime`, `maxDaysAhead`) resolve against the clock * rather than a fixed date, and the SDK skips them entirely when no clock is * passed — so leaving it out silently reopens every day a lead time was meant * to close. */ interface DatePickerProps { id?: string; value: string; // 'YYYY-MM-DD' or '' onChange: (value: string) => void; required?: boolean; /** The field's `dateAvailability`, passed straight through from the API. */ availability?: DateAvailabilityConstraints | null; /** * Extra per-day gate ANDed with `isCalendarDateAllowed`. DATETIME fields use * it to grey out days that pass the calendar rules but have no business-hours * window — those are closed all day, and offering them is what produces a * "no business hours are configured for this day" 400 after the shopper has * already committed to a date. */ isDateSelectable?: (dateYYYYMMDD: string) => boolean; /** The store's IANA timezone, from `getStoreInfo().timezone`. Never the browser's. */ timezone?: string; placeholder: string; todayLabel: string; clearLabel: string; prevMonthLabel: string; nextMonthLabel: string; className?: string; } function pad(n: number): string { return String(n).padStart(2, '0'); } function toYMD(y: number, m: number, d: number): string { return `${y}-${pad(m + 1)}-${pad(d)}`; } function parseYMD(value: string): { y: number; m: number; d: number } | null { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (!match) return null; return { y: Number(match[1]), m: Number(match[2]) - 1, d: Number(match[3]) }; } export function DatePicker({ id, value, onChange, required, availability, isDateSelectable, timezone, placeholder, todayLabel, clearLabel, prevMonthLabel, nextMonthLabel, className, }: DatePickerProps) { const [open, setOpen] = useState(false); const [locale, setLocale] = useState(undefined); const [rtl, setRtl] = useState(false); const rootRef = useRef(null); const triggerRef = useRef(null); useEffect(() => { setLocale(document.documentElement.lang || undefined); setRtl(document.documentElement.dir === 'rtl'); }, []); const selected = parseYMD(value); const today = new Date(); const [view, setView] = useState(() => { const initial = selected ?? { y: today.getFullYear(), m: today.getMonth() }; return { y: initial.y, m: initial.m }; }); useEffect(() => { if (!open) return; function onDocClick(e: MouseEvent) { if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); } function onKey(e: KeyboardEvent) { if (e.key === 'Escape') { setOpen(false); triggerRef.current?.focus(); } } document.addEventListener('mousedown', onDocClick); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey); }; }, [open]); function isDisabled(y: number, m: number, d: number): boolean { const ymd = toYMD(y, m, d); // `now` is deliberately left to the SDK's default so the calendar re-reads // the real clock on every render: a shopper who sits on the page past the // cutoff sees the day close under them rather than 400 on submit. if (!isCalendarDateAllowed(ymd, availability, timezone ? { timezone } : undefined)) return true; return isDateSelectable ? !isDateSelectable(ymd) : false; } function selectDay(y: number, m: number, d: number) { if (isDisabled(y, m, d)) return; onChange(toYMD(y, m, d)); setView({ y, m }); setOpen(false); triggerRef.current?.focus(); } function shiftMonth(delta: number) { setView((v) => { let m = v.m + delta; let y = v.y; if (m < 0) { m = 11; y -= 1; } else if (m > 11) { m = 0; y += 1; } return { y, m }; }); } const monthLabel = new Date(view.y, view.m, 1).toLocaleDateString(locale, { month: 'long', year: 'numeric', }); const weekdayLabels = Array.from({ length: 7 }, (_, i) => { // A Sunday-anchored week (2023-01-01 was a Sunday) formatted with the // page's own locale — matches whatever week-start convention that // locale's date formatting implies. const d = new Date(2023, 0, 1 + i); return d.toLocaleDateString(locale, { weekday: 'narrow' }); }); const firstOfMonth = new Date(view.y, view.m, 1); const startWeekday = firstOfMonth.getDay(); const daysInMonth = new Date(view.y, view.m + 1, 0).getDate(); const daysInPrevMonth = new Date(view.y, view.m, 0).getDate(); const cells: Array<{ day: number; muted: boolean; y: number; m: number }> = []; for (let i = startWeekday - 1; i >= 0; i--) { const m = view.m === 0 ? 11 : view.m - 1; const y = view.m === 0 ? view.y - 1 : view.y; cells.push({ day: daysInPrevMonth - i, muted: true, y, m }); } for (let d = 1; d <= daysInMonth; d++) { cells.push({ day: d, muted: false, y: view.y, m: view.m }); } while (cells.length % 7 !== 0) { const idx = cells.length - startWeekday - daysInMonth + 1; const m = view.m === 11 ? 0 : view.m + 1; const y = view.m === 11 ? view.y + 1 : view.y; cells.push({ day: idx, muted: true, y, m }); } const displayValue = selected ? new Date(selected.y, selected.m, selected.d).toLocaleDateString(locale) : ''; return (
{open && (
{monthLabel}
{weekdayLabels.map((w, i) => ( {w} ))}
{cells.map((c, i) => { const disabled = c.muted || isDisabled(c.y, c.m, c.day); const isToday = !c.muted && c.y === today.getFullYear() && c.m === today.getMonth() && c.day === today.getDate(); const isSelected = selected && !c.muted && c.y === selected.y && c.m === selected.m && c.day === selected.d; return ( ); })}
)}
); }