'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 (