316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
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 (
|
||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||
<rect x="3.5" y="5" width="17" height="16" rx="2" stroke="currentColor" strokeWidth="1.4" />
|
||
<path d="M3.5 9.5h17M8 3v4M16 3v4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
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<HTMLDivElement>(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 (
|
||
<div className="datetime-field">
|
||
<div className="date-input-group" ref={containerRef}>
|
||
<input
|
||
type="text"
|
||
inputMode="numeric"
|
||
placeholder="TT.MM.JJJJ"
|
||
pattern="\d{2}\.\d{2}\.\d{4}"
|
||
maxLength={10}
|
||
value={dateText}
|
||
onChange={(e) => handleTextChange(e.target.value)}
|
||
required
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="date-picker-trigger"
|
||
aria-label="Kalender öffnen"
|
||
aria-expanded={isCalendarOpen}
|
||
onClick={() => setIsCalendarOpen((open) => !open)}
|
||
>
|
||
<CalendarIcon />
|
||
</button>
|
||
|
||
{isCalendarOpen && (
|
||
<div className="date-popover" role="dialog" aria-label="Datum auswählen">
|
||
<div className="date-popover-header">
|
||
<button
|
||
type="button"
|
||
aria-label="Vorheriger Monat"
|
||
disabled={isAtEarliestMonth}
|
||
onClick={() => goToMonth(-1)}
|
||
>
|
||
‹
|
||
</button>
|
||
<span>{monthLabel}</span>
|
||
<button
|
||
type="button"
|
||
aria-label="Nächster Monat"
|
||
disabled={isAtLatestMonth}
|
||
onClick={() => goToMonth(1)}
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
<div className="date-popover-weekdays">
|
||
{WEEKDAY_HEADER_LABELS.map((label) => (
|
||
<span key={label}>{label}</span>
|
||
))}
|
||
</div>
|
||
<div className="date-popover-grid">
|
||
{getMonthGrid(view.year, view.month)
|
||
.flat()
|
||
.map((cell, index) => (
|
||
<button
|
||
key={cell?.dateStr ?? `blank-${index}`}
|
||
type="button"
|
||
className={cell?.dateStr === date ? 'is-selected' : undefined}
|
||
disabled={!cell || !isSelectableDate(cell.dateStr)}
|
||
onClick={() => cell && handleDayPick(cell.dateStr)}
|
||
>
|
||
{cell?.day ?? ''}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<select
|
||
value={time}
|
||
onChange={(e) => handleTimeChange(e.target.value)}
|
||
disabled={availableTimeOptions.length === 0}
|
||
required
|
||
>
|
||
<option value="" disabled>
|
||
{timePlaceholder}
|
||
</option>
|
||
{timeOptions.map((option) => (
|
||
<option key={option} value={option} disabled={bookedTimesSet.has(option)}>
|
||
{option}
|
||
{bookedTimesSet.has(option) ? ' (belegt)' : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)
|
||
}
|