Files
promo_steakhouse/src/components/reservation-datetime-field.tsx
T
lukas c64889c3b1
CI / build-and-test (pull_request) Failing after 49s
wip
2026-08-27 10:04:59 +02:00

363 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 long a reservation booked at a slot occupies the table — a slot too close
// to closing time to fit the whole reservation is left out of the list entirely.
durationMinutes?: 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,
durationMinutes = RESERVATION_INTERVAL_MINUTES,
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)
// Only locks the background on mobile, where the popover becomes a fixed,
// full-screen overlay that would otherwise let the page behind it scroll —
// on desktop the popover is just a small anchored dropdown, and pinning the
// body there only removes the scrollbar and shifts the whole page sideways.
// Matches the `@media (max-width: 30rem)` breakpoint the popover-to-modal
// CSS switches on.
const isMobile = window.matchMedia('(max-width: 30rem)').matches
if (!isMobile) {
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
}
}
// `overflow: hidden` alone doesn't stop touch-driven rubber-band scrolling on
// iOS Safari, so the body is pinned in place instead and the scroll position
// restored after.
const scrollY = window.scrollY
const body = document.body.style
const previousStyle = { position: body.position, top: body.top, left: body.left, right: body.right }
body.position = 'fixed'
body.top = `-${scrollY}px`
body.left = '0'
body.right = '0'
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
body.position = previousStyle.position
body.top = previousStyle.top
body.left = previousStyle.left
body.right = previousStyle.right
// `behavior: 'instant'` is required, not just the default — the page sets
// `scroll-behavior: smooth` globally, which would otherwise animate this
// restore from the top of the page instead of snapping back in place.
window.scrollTo({ top: scrollY, left: 0, behavior: 'instant' })
}
// 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, durationMinutes)
}
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-backdrop" onClick={() => setIsCalendarOpen(false)}>
<div
className="date-popover"
role="dialog"
aria-modal="true"
aria-label="Datum auswählen"
onClick={(event) => event.stopPropagation()}
>
<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>
)}
</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>
)
}