@@ -0,0 +1,315 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user