"use client"; import React from "react"; import { buildDatePresets } from "@/lib/frontend/search/datePresets"; import { getSearchDateRangeValidation } from "@/lib/frontend/search/searchDateValidation"; import { formatIsoDateDe, formatIsoDateRangeLabelDe, toDateFromIsoDateYmd, toIsoDateYmdFromDate, } from "@/lib/frontend/search/dateRange"; const ACTIVE_FIELD = Object.freeze({ FROM: "from", TO: "to", }); function hasOwn(obj, key) { return Object.prototype.hasOwnProperty.call(obj || {}, key); } function focusRef(ref) { requestAnimationFrame(() => ref?.current?.focus?.()); } function normalizeDayClickArgs(args) { // react-day-picker handler signatures have differed across versions. // We normalize both of these variants: // - onDayClick(day, modifiers, event) // - onDayClick(event, day, modifiers) const a0 = args?.[0]; const a1 = args?.[1]; const a2 = args?.[2]; // Common: (day, modifiers) if (a0 instanceof Date) return { day: a0, modifiers: a1 || null }; // Event-first: (event, day, modifiers) if (a1 instanceof Date) return { day: a1, modifiers: a2 || null }; return { day: null, modifiers: null }; } function buildCalendarState({ fromDate, toDate, isRangeInvalid }) { let calendarSelected = undefined; let invalidInterval = null; if (fromDate && toDate) { if (isRangeInvalid) { const min = fromDate < toDate ? fromDate : toDate; const max = fromDate < toDate ? toDate : fromDate; calendarSelected = { from: min, to: max }; invalidInterval = { from: min, to: max }; } else { calendarSelected = { from: fromDate, to: toDate }; } } else if (fromDate) { calendarSelected = { from: fromDate, to: undefined }; } else if (toDate) { // "to only" -> visually represent as a single-day range calendarSelected = { from: toDate, to: toDate }; } const calendarModifiers = isRangeInvalid && invalidInterval ? { invalid_range: invalidInterval, invalid_range_edge: [fromDate, toDate].filter(Boolean), } : undefined; const calendarModifiersClassNames = isRangeInvalid && invalidInterval ? { invalid_range: "bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive", invalid_range_edge: "!bg-destructive !text-white hover:!bg-destructive/90", } : undefined; return { calendarSelected, calendarModifiers, calendarModifiersClassNames }; } export function useSearchDateRangePicker({ from, to, onDateRangeChange, isSubmitting, }) { const disabled = Boolean(isSubmitting); const [open, setOpen] = React.useState(false); const [activeField, setActiveField] = React.useState(ACTIVE_FIELD.FROM); const fromRef = React.useRef(null); const toRef = React.useRef(null); const fromDate = React.useMemo(() => toDateFromIsoDateYmd(from), [from]); const toDate = React.useMemo(() => toDateFromIsoDateYmd(to), [to]); const validation = React.useMemo(() => { return getSearchDateRangeValidation(from ?? null, to ?? null); }, [from, to]); const isRangeInvalid = validation?.code === "VALIDATION_SEARCH_RANGE"; const now = React.useMemo(() => new Date(), []); const presets = React.useMemo(() => buildDatePresets({ now }), [now]); const presetsRow1 = React.useMemo(() => presets.slice(0, 4), [presets]); const presetsRow2 = React.useMemo(() => presets.slice(4), [presets]); const [month, setMonth] = React.useState(() => { return fromDate || toDate || new Date(); }); React.useEffect(() => { if (!open) return; // Only run on open transition. // We intentionally do not depend on from/to to avoid focus jumps while clicking. if (from && !to) { setActiveField(ACTIVE_FIELD.TO); focusRef(toRef); } else { setActiveField(ACTIVE_FIELD.FROM); focusRef(fromRef); } setMonth(fromDate || toDate || new Date()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); const safeOnDateRangeChange = React.useCallback( (patch) => { if (typeof onDateRangeChange !== "function") return; const hasFrom = hasOwn(patch, "from"); const hasTo = hasOwn(patch, "to"); onDateRangeChange({ from: hasFrom ? patch.from : from ?? null, to: hasTo ? patch.to : to ?? null, }); }, [onDateRangeChange, from, to] ); const { calendarSelected, calendarModifiers, calendarModifiersClassNames } = React.useMemo(() => { return buildCalendarState({ fromDate, toDate, isRangeInvalid }); }, [fromDate, toDate, isRangeInvalid]); const calendarKey = React.useMemo(() => { return `${from || ""}|${to || ""}|${isRangeInvalid ? "inv" : "ok"}`; }, [from, to, isRangeInvalid]); const summary = React.useMemo(() => { return formatIsoDateRangeLabelDe({ from, to }) || "Zeitraum auswählen"; }, [from, to]); const fromDisplay = React.useMemo(() => formatIsoDateDe(from) || "", [from]); const toDisplay = React.useMemo(() => formatIsoDateDe(to) || "", [to]); const handlePickDay = React.useCallback( (...args) => { if (disabled) return; const { day, modifiers } = normalizeDayClickArgs(args); if (!(day instanceof Date) || Number.isNaN(day.getTime())) return; if (modifiers?.disabled) return; const iso = toIsoDateYmdFromDate(day); if (!iso) return; if (activeField === ACTIVE_FIELD.FROM) { safeOnDateRangeChange({ from: iso }); setActiveField(ACTIVE_FIELD.TO); focusRef(toRef); return; } safeOnDateRangeChange({ to: iso }); setActiveField(ACTIVE_FIELD.TO); focusRef(toRef); }, [disabled, activeField, safeOnDateRangeChange] ); const handleClearFrom = React.useCallback( (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); if (disabled) return; safeOnDateRangeChange({ from: null }); setActiveField(ACTIVE_FIELD.FROM); focusRef(fromRef); }, [disabled, safeOnDateRangeChange] ); const handleClearTo = React.useCallback( (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); if (disabled) return; safeOnDateRangeChange({ to: null }); setActiveField(ACTIVE_FIELD.TO); focusRef(toRef); }, [disabled, safeOnDateRangeChange] ); const handleReset = React.useCallback(() => { if (disabled) return; safeOnDateRangeChange({ from: null, to: null }); setActiveField(ACTIVE_FIELD.FROM); setMonth(new Date()); focusRef(fromRef); }, [disabled, safeOnDateRangeChange]); const applyPreset = React.useCallback( (preset) => { if (disabled) return; if (!preset?.from || !preset?.to) return; safeOnDateRangeChange({ from: preset.from, to: preset.to }); const nextMonth = toDateFromIsoDateYmd(preset.from) || toDateFromIsoDateYmd(preset.to) || new Date(); setMonth(nextMonth); setActiveField(ACTIVE_FIELD.TO); focusRef(toRef); }, [disabled, safeOnDateRangeChange] ); return { disabled, open, setOpen, activeField, setActiveField, fromRef, toRef, month, setMonth, summary, fromDisplay, toDisplay, presetsRow1, presetsRow2, calendarKey, calendarSelected, calendarModifiers, calendarModifiersClassNames, handlePickDay, handleClearFrom, handleClearTo, handleReset, applyPreset, }; }