import { useEffect, useRef, useState } from 'react' import { formatDateOnly } from '#/lib/datetime' import { generateTimeOptions, getOpeningRangesForWeekday, RESERVATION_INTERVAL_MINUTES } from '#/lib/opening-hours' type Props = { value: string onChange: (value: string) => void // "HH:MM" times already taken (e.g. by another reservation on the same table) // for the currently selected date — shown disabled rather than removed, so the // slot doesn't look like it doesn't exist, just that it isn't free. bookedTimes?: string[] // How far apart selectable times are, and how far in advance a booking must be // made — both configurable restaurant settings, falling back to sane defaults. intervalMinutes?: number minLeadTimeMinutes?: number // How far into the future a booking may be made. Undefined leaves the horizon open, // since not every caller of this field needs the restriction. maxLeadTimeDays?: number } type DayCell = { day: number; dateStr: string } | null function splitValue(value: string) { const [date = '', time = ''] = value.split('T') return { date, time } } function getWeekday(dateStr: string) { return new Date(`${dateStr}T00:00`).getDay() } function pad2(n: number) { return String(n).padStart(2, '0') } function isoToDisplay(iso: string) { if (!iso) return '' const [year, month, day] = iso.split('-') return `${day}.${month}.${year}` } // Progressively inserts dots as digits are typed, e.g. "22081" -> "22.08.1". function maskDateInput(raw: string) { const digits = raw.replace(/\D/g, '').slice(0, 8) let out = digits.slice(0, 2) if (digits.length > 2) out += '.' + digits.slice(2, 4) if (digits.length > 4) out += '.' + digits.slice(4, 8) return out } // Returns an ISO "YYYY-MM-DD" string, or null if the text isn't a complete, real calendar date. function parseDisplayDate(raw: string) { const digits = raw.replace(/\D/g, '') if (digits.length !== 8) return null const day = Number(digits.slice(0, 2)) const month = Number(digits.slice(2, 4)) const year = Number(digits.slice(4, 8)) const parsed = new Date(year, month - 1, day) if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) { return null } return `${digits.slice(4, 8)}-${digits.slice(2, 4)}-${digits.slice(0, 2)}` } // Monday-first weeks covering the given month, with null filler cells for days // that belong to the adjacent month. function getMonthGrid(year: number, month: number): DayCell[][] { const firstOfMonth = new Date(year, month, 1) const leadingBlanks = (firstOfMonth.getDay() + 6) % 7 const daysInMonth = new Date(year, month + 1, 0).getDate() const cells: DayCell[] = Array(leadingBlanks).fill(null) for (let day = 1; day <= daysInMonth; day++) { cells.push({ day, dateStr: `${year}-${pad2(month + 1)}-${pad2(day)}` }) } while (cells.length % 7 !== 0) cells.push(null) const weeks: DayCell[][] = [] for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7)) return weeks } const WEEKDAY_HEADER_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] function CalendarIcon() { return ( ) } export function ReservationDateTimeField({ value, onChange, bookedTimes = [], intervalMinutes = RESERVATION_INTERVAL_MINUTES, minLeadTimeMinutes = 0, maxLeadTimeDays, }: Props) { const { date, time } = splitValue(value) const [dateText, setDateText] = useState(() => isoToDisplay(date)) const [isCalendarOpen, setIsCalendarOpen] = useState(false) const [view, setView] = useState(() => { const [year, month] = (date || formatDateOnly(new Date())).split('-') return { year: Number(year), month: Number(month) - 1 } }) const containerRef = useRef(null) useEffect(() => { setDateText(isoToDisplay(date)) }, [date]) useEffect(() => { if (!isCalendarOpen) return const [year, month] = (date || formatDateOnly(new Date())).split('-') setView({ year: Number(year), month: Number(month) - 1 }) function handleOutsideClick(event: MouseEvent) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsCalendarOpen(false) } } function handleKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') setIsCalendarOpen(false) } document.addEventListener('mousedown', handleOutsideClick) document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('mousedown', handleOutsideClick) document.removeEventListener('keydown', handleKeyDown) } // Only re-run when the popover opens/closes — reopening should reset to the // currently selected month, not follow every keystroke in the text field. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isCalendarOpen]) const today = formatDateOnly(new Date()) const isPast = date !== '' && date < today const earliestAllowedTime = new Date(Date.now() + minLeadTimeMinutes * 60_000) const latestDate = maxLeadTimeDays === undefined ? null : formatDateOnly(new Date(Date.now() + maxLeadTimeDays * 86_400_000)) const isBeyondHorizon = date !== '' && latestDate !== null && date > latestDate // Past dates and dates beyond the booking horizon never have slots — shared by both // the render below and commitDate, so a date committed from anywhere (typed, or // otherwise) can't slip through with a leftover, still-"valid"-looking time. function optionsForDate(dateStr: string) { if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return [] return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes) } function isSelectableDate(dateStr: string) { return optionsForDate(dateStr).length > 0 } const allOptions = optionsForDate(date) // Compares full timestamps (not just "is it today") so a lead time longer than // what's left of today correctly spills over into tomorrow's early slots too. const timeOptions = allOptions.filter( (option) => new Date(`${date}T${option}`).getTime() >= earliestAllowedTime.getTime(), ) const bookedTimesSet = new Set(bookedTimes) const availableTimeOptions = timeOptions.filter((option) => !bookedTimesSet.has(option)) const isClosed = date !== '' && !isPast && !isBeyondHorizon && allOptions.length === 0 const noSlotsLeft = date !== '' && !isPast && allOptions.length > 0 && availableTimeOptions.length === 0 const dateIncomplete = parseDisplayDate(dateText) === null function commitDate(newDate: string) { const newOptions = optionsForDate(newDate) const stillValid = newOptions.includes(time) onChange(`${newDate}T${stillValid ? time : newOptions[0] ?? ''}`) } function handleTextChange(raw: string) { const masked = maskDateInput(raw) setDateText(masked) const parsed = parseDisplayDate(masked) if (parsed) { commitDate(parsed) } } function handleDayPick(dateStr: string) { commitDate(dateStr) setIsCalendarOpen(false) } function handleTimeChange(newTime: string) { onChange(`${date}T${newTime}`) } let timePlaceholder = 'Uhrzeit wählen' if (dateIncomplete) timePlaceholder = 'Datum eingeben' else if (isPast) timePlaceholder = 'Datum liegt in der Vergangenheit' else if (isBeyondHorizon) timePlaceholder = 'Datum liegt zu weit in der Zukunft' else if (isClosed) timePlaceholder = 'An diesem Tag geschlossen' else if (noSlotsLeft) timePlaceholder = 'Keine Zeiten mehr verfügbar' const monthLabel = new Date(view.year, view.month, 1).toLocaleDateString('de-DE', { month: 'long', year: 'numeric', }) const [todayYear, todayMonthStr] = today.split('-') const isAtEarliestMonth = view.year === Number(todayYear) && view.month === Number(todayMonthStr) - 1 const isAtLatestMonth = (() => { if (latestDate === null) return false const [latestYear, latestMonthStr] = latestDate.split('-') return view.year === Number(latestYear) && view.month === Number(latestMonthStr) - 1 })() function goToMonth(offset: number) { setView((v) => { const total = v.year * 12 + v.month + offset return { year: Math.floor(total / 12), month: ((total % 12) + 12) % 12 } }) } return ( handleTextChange(e.target.value)} required /> setIsCalendarOpen((open) => !open)} > {isCalendarOpen && ( goToMonth(-1)} > ‹ {monthLabel} goToMonth(1)} > › {WEEKDAY_HEADER_LABELS.map((label) => ( {label} ))} {getMonthGrid(view.year, view.month) .flat() .map((cell, index) => ( cell && handleDayPick(cell.dateStr)} > {cell?.day ?? ''} ))} )} handleTimeChange(e.target.value)} disabled={availableTimeOptions.length === 0} required > {timePlaceholder} {timeOptions.map((option) => ( {option} {bookedTimesSet.has(option) ? ' (belegt)' : ''} ))} ) }