import { useState } from 'react' import { createFileRoute, useRouter } from '@tanstack/react-router' import { ReservationConfirmedModal } from '#/components/reservation-confirmed-modal' import { ReservationDateTimeField } from '#/components/reservation-datetime-field' import { SiteFooter } from '#/components/site-footer' import { SiteHeader } from '#/components/site-header' import { formatDateOnly } from '#/lib/datetime' import { generateTimeOptions, getNextAvailableSlot, getOpeningHoursForDisplay, getOpeningRangesForWeekday, getTodayLabelBerlin, } from '#/lib/opening-hours' import { createReservation, getReservationAvailability, getTables } from '#/lib/reservations' import { getReservationSettings } from '#/lib/settings' export const Route = createFileRoute('/')({ component: Home, loader: async () => { const [tables, settings, availability] = await Promise.all([ getTables(), getReservationSettings(), getReservationAvailability(), ]) return { tables, settings, availability } }, }) const OPENING_HOURS = getOpeningHoursForDisplay() const confirmedDateFormatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'full' }) const confirmedTimeFormatter = new Intl.DateTimeFormat('de-DE', { timeStyle: 'short' }) const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ // Also used as the phone input's `pattern` attribute below, so the two stay in sync. const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}` const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`) type ConfirmedReservation = { customerName: string customerEmail: string tableName: string date: string startTime: string endTime: string partySize: number } function FlameIcon() { return ( ) } function Home() { const router = useRouter() const { tables, settings, availability } = Route.useLoaderData() const [tableId, setTableId] = useState(tables[0]?.id ?? '') const [customerName, setCustomerName] = useState('') const [customerEmail, setCustomerEmail] = useState('') const [customerPhone, setCustomerPhone] = useState('') const [message, setMessage] = useState('') const [partySize, setPartySize] = useState(2) const [acceptsTerms, setAcceptsTerms] = useState(false) const [reservationTime, setReservationTime] = useState(() => { const slot = getNextAvailableSlot( new Date(Date.now() + settings.minLeadTimeMinutes * 60_000), settings.reservationIntervalMinutes, settings.reservationDurationMinutes, ) return slot ? `${slot.date}T${slot.time}` : '' }) const [error, setError] = useState(null) const [confirmedReservation, setConfirmedReservation] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) const todayName = getTodayLabelBerlin() const selectedTable = tables.find((table) => String(table.id) === String(tableId)) const [selectedDate] = reservationTime.split('T') const otherReservationsOnSelection = availability.filter( (r) => String(r.tableId) === String(tableId) && formatDateOnly(new Date(r.reservationTime)) === selectedDate, ) // `minLeadTimeMinutes` is reused here as a table turnover buffer, extending each // existing reservation's taken window on both sides so back-to-back bookings on the // same table always leave that much of a gap. const turnoverBufferMs = settings.minLeadTimeMinutes * 60_000 const bookedTimesForForm = selectedDate ? generateTimeOptions( getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()), settings.reservationIntervalMinutes, settings.reservationDurationMinutes, ).filter((option) => { const slotStart = new Date(`${selectedDate}T${option}`).getTime() return otherReservationsOnSelection.some((r) => { const otherStart = new Date(r.reservationTime).getTime() - turnoverBufferMs const otherEnd = otherStart + r.durationMinutes * 60_000 + 2 * turnoverBufferMs return slotStart >= otherStart && slotStart < otherEnd }) }) : [] // Mirrors the fields' own `required`/`type`/`pattern`/`min`/`max` constraints, so // the submit button is disabled until they'd all actually pass — instead of only // catching a missing table/time/consent on submit and leaving everything else to // the browser's native validation popup. const isFormValid = Boolean(tableId) && customerName.trim().length > 0 && EMAIL_PATTERN.test(customerEmail) && PHONE_PATTERN.test(customerPhone) && partySize >= 1 && partySize <= 20 && Boolean(reservationTime.split('T')[1]) && acceptsTerms async function handleSubmit(event: React.FormEvent) { event.preventDefault() setError(null) if (!tableId) { setError('Bitte wählen Sie einen Tisch aus') return } if (!reservationTime.split('T')[1]) { setError('Bitte wählen Sie eine gültige Uhrzeit aus') return } if (!acceptsTerms) { setError('Bitte stimmen Sie der Verarbeitung Ihrer Daten zu') return } setIsSubmitting(true) try { const created = await createReservation({ data: { tableId: Number(tableId), customerName, customerEmail, customerPhone, message, partySize: Number(partySize), reservationTime: new Date(reservationTime).toISOString(), }, }) const start = new Date(created.reservationTime) const end = new Date(start.getTime() + created.durationMinutes * 60_000) setConfirmedReservation({ customerName: created.customerName, customerEmail: created.customerEmail, tableName: selectedTable?.name ?? '', date: confirmedDateFormatter.format(start), startTime: confirmedTimeFormatter.format(start), endTime: confirmedTimeFormatter.format(end), partySize: created.partySize, }) setCustomerName('') setCustomerEmail('') setCustomerPhone('') setMessage('') setPartySize(2) setAcceptsTerms(false) await router.invalidate() } catch (err) { setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen') } finally { setIsSubmitting(false) } } return ( <>

Vom Feuer verwöhnt, mit Liebe serviert

Dicke Steaks, knisterndes Holzkohlefeuer und Gelächter am Nachbartisch – so schmeckt ein guter Abend mitten in Meppen. Reservieren Sie Ihren Tisch, wir legen schon auf.

Wann wir für Sie da sind

Öffnungszeiten

    {OPENING_HOURS.map(({ day, hours }) => (
  • {day} {hours ? ( {hours.join(' · ')} ) : ( geschlossen )}
  • ))}
Wir freuen uns auf Sie

Tisch reservieren

{settings.tableDisplayMode === 'overview' ? ( settings.tableOverviewImageUrl && (
Übersicht unserer Tische {tables .filter((table) => table.markerX !== null && table.markerY !== null) .map((table) => ( ))}
) ) : ( selectedTable?.imageUrl && ( {`Ansicht ) )}