430 lines
15 KiB
TypeScript
430 lines
15 KiB
TypeScript
import { useState } from 'react'
|
||
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
||
|
||
import { PdfMenu } from '#/components/pdf-menu'
|
||
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 Home() {
|
||
const router = useRouter()
|
||
const { tables, settings, availability } = Route.useLoaderData()
|
||
|
||
const [tableId, setTableId] = useState<number | string>(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<string | null>(null)
|
||
const [confirmedReservation, setConfirmedReservation] = useState<ConfirmedReservation | null>(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 (
|
||
<>
|
||
<SiteHeader />
|
||
|
||
<section className="hero" id="top">
|
||
<img className="hero-flame" src="/logo.svg" alt="" aria-hidden="true" />
|
||
<h1>
|
||
Vom Feuer verwöhnt, <span>mit Liebe serviert</span>
|
||
</h1>
|
||
<p>
|
||
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.
|
||
</p>
|
||
<div className="hero-actions">
|
||
<a className="btn btn-primary" href="#reservieren">
|
||
Tisch reservieren
|
||
</a>
|
||
<a className="btn btn-secondary" href="#oeffnungszeiten">
|
||
Öffnungszeiten
|
||
</a>
|
||
</div>
|
||
</section>
|
||
|
||
<main className="page">
|
||
<section className="section" id="oeffnungszeiten">
|
||
<div className="section-grid">
|
||
<div>
|
||
<span className="section-kicker">Wann wir für Sie da sind</span>
|
||
<h2 className="section-heading">Öffnungszeiten</h2>
|
||
</div>
|
||
<ul className="hours-list">
|
||
{OPENING_HOURS.map(({ day, hours }) => (
|
||
<li key={day} className={day === todayName ? 'today' : undefined}>
|
||
<span className="day">{day}</span>
|
||
{hours ? (
|
||
<span>{hours.join(' · ')}</span>
|
||
) : (
|
||
<span className="hours-closed">geschlossen</span>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="section" id="speisekarte">
|
||
<div className="section-grid">
|
||
<div>
|
||
<span className="section-kicker">Was uns schmeckt</span>
|
||
<h2 className="section-heading">Speisekarte</h2>
|
||
</div>
|
||
<PdfMenu src="/menu.pdf" />
|
||
</div>
|
||
</section>
|
||
|
||
<section className="section" id="reservieren">
|
||
<div className="section-grid">
|
||
<div>
|
||
<span className="section-kicker">Wir freuen uns auf Sie</span>
|
||
<h2 className="section-heading">Tisch reservieren</h2>
|
||
</div>
|
||
|
||
<form className="reservation-form" onSubmit={handleSubmit}>
|
||
<label>
|
||
Tisch
|
||
<select value={tableId} onChange={(e) => setTableId(e.target.value)} required>
|
||
<option value="" disabled>
|
||
Tisch auswählen
|
||
</option>
|
||
{tables.map((table) => (
|
||
<option key={table.id} value={table.id}>
|
||
{table.name} ({table.seats} Plätze)
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
|
||
{settings.tableDisplayMode === 'overview' ? (
|
||
settings.tableOverviewImageUrl && (
|
||
<div className="table-overview-wrap">
|
||
<img className="table-overview-image" src={settings.tableOverviewImageUrl} alt="Übersicht unserer Tische" />
|
||
{tables
|
||
.filter((table) => table.markerX !== null && table.markerY !== null)
|
||
.map((table) => (
|
||
<button
|
||
key={table.id}
|
||
type="button"
|
||
className={`table-marker${String(table.id) === String(tableId) ? ' is-selected' : ''}`}
|
||
style={{ left: `${table.markerX}%`, top: `${table.markerY}%` }}
|
||
onClick={() => setTableId(table.id)}
|
||
aria-label={`${table.name} auswählen`}
|
||
>
|
||
<span className="table-marker-label">{table.tableNumber ?? '?'}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
) : (
|
||
selectedTable?.imageUrl && (
|
||
<img
|
||
className="table-preview"
|
||
src={selectedTable.imageUrl}
|
||
alt={`Ansicht von ${selectedTable.name}`}
|
||
/>
|
||
)
|
||
)}
|
||
|
||
<div className="form-row">
|
||
<label>
|
||
Name
|
||
<input
|
||
type="text"
|
||
value={customerName}
|
||
onChange={(e) => setCustomerName(e.target.value)}
|
||
required
|
||
/>
|
||
</label>
|
||
|
||
<label>
|
||
E-Mail
|
||
<input
|
||
type="email"
|
||
value={customerEmail}
|
||
onChange={(e) => setCustomerEmail(e.target.value)}
|
||
required
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label>
|
||
Telefon
|
||
<input
|
||
type="tel"
|
||
inputMode="tel"
|
||
pattern={PHONE_PATTERN_SOURCE}
|
||
title="Nur Ziffern, Leerzeichen und + ( ) - ."
|
||
value={customerPhone}
|
||
onChange={(e) => setCustomerPhone(e.target.value)}
|
||
required
|
||
/>
|
||
</label>
|
||
|
||
<label>
|
||
Anzahl Personen
|
||
<input
|
||
type="number"
|
||
inputMode="numeric"
|
||
min={1}
|
||
max={20}
|
||
value={partySize}
|
||
onChange={(e) => setPartySize(Number(e.target.value))}
|
||
required
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<label>
|
||
Datum & Uhrzeit
|
||
<ReservationDateTimeField
|
||
value={reservationTime}
|
||
onChange={setReservationTime}
|
||
bookedTimes={bookedTimesForForm}
|
||
intervalMinutes={settings.reservationIntervalMinutes}
|
||
minLeadTimeMinutes={settings.minLeadTimeMinutes}
|
||
durationMinutes={settings.reservationDurationMinutes}
|
||
maxLeadTimeDays={settings.maxLeadTimeDays}
|
||
/>
|
||
</label>
|
||
|
||
<label>
|
||
Nachricht (optional)
|
||
<textarea
|
||
rows={3}
|
||
maxLength={500}
|
||
placeholder="Allergien, ein besonderer Anlass, Sitzwünsche …"
|
||
value={message}
|
||
onChange={(e) => setMessage(e.target.value)}
|
||
/>
|
||
</label>
|
||
|
||
<p className="field-hint">
|
||
Verfügbare Uhrzeiten richten sich nach unseren{' '}
|
||
<a href="#oeffnungszeiten">Öffnungszeiten</a>.
|
||
</p>
|
||
|
||
<label className="radio-option consent-option">
|
||
<input
|
||
type="checkbox"
|
||
checked={acceptsTerms}
|
||
onChange={(e) => setAcceptsTerms(e.target.checked)}
|
||
required
|
||
/>
|
||
<span>
|
||
Ich stimme der Verarbeitung meiner Daten gemäß der{' '}
|
||
<a href="/impressum#datenschutz" target="_blank" rel="noopener noreferrer">
|
||
Datenschutzerklärung
|
||
</a>{' '}
|
||
zu.
|
||
</span>
|
||
</label>
|
||
|
||
<button type="submit" disabled={isSubmitting || !isFormValid}>
|
||
{isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'}
|
||
</button>
|
||
|
||
{error && <p className="message message-error">{error}</p>}
|
||
</form>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="section" id="kontakt">
|
||
<div className="section-grid">
|
||
<div>
|
||
<span className="section-kicker">Hier finden Sie uns</span>
|
||
<h2 className="section-heading">Kontakt</h2>
|
||
</div>
|
||
<ul className="contact-list">
|
||
<li>
|
||
<span className="label">Adresse</span>
|
||
<span>
|
||
Meppener Straße 8
|
||
<br />
|
||
49716 Meppen
|
||
</span>
|
||
</li>
|
||
<li>
|
||
<span className="label">Telefon</span>
|
||
<a href="tel:+49593112750">05931 - 12750</a>
|
||
</li>
|
||
<li>
|
||
<span className="label">E-Mail</span>
|
||
<a href="mailto:info@steakhaus-meppen.de">info@steakhaus-meppen.de</a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<SiteFooter />
|
||
|
||
{confirmedReservation && (
|
||
<ReservationConfirmedModal
|
||
open
|
||
customerName={confirmedReservation.customerName}
|
||
customerEmail={confirmedReservation.customerEmail}
|
||
tableName={confirmedReservation.tableName}
|
||
date={confirmedReservation.date}
|
||
startTime={confirmedReservation.startTime}
|
||
endTime={confirmedReservation.endTime}
|
||
partySize={confirmedReservation.partySize}
|
||
onClose={() => setConfirmedReservation(null)}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|