wip
CI / build-and-test (pull_request) Failing after 49s

This commit is contained in:
2026-08-27 10:04:59 +02:00
parent 55a51a521d
commit c64889c3b1
66 changed files with 12623 additions and 483 deletions
+223 -37
View File
@@ -1,28 +1,52 @@
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 { formatDateTimeLocal } from '#/lib/datetime'
import { createReservation, getTables } from '#/lib/reservations'
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 = await getTables()
return { tables }
const [tables, settings, availability] = await Promise.all([
getTables(),
getReservationSettings(),
getReservationAvailability(),
])
return { tables, settings, availability }
},
})
const OPENING_HOURS = [
{ day: 'Montag', hours: null },
{ day: 'Dienstag', hours: ['17:00 – 21:30'] },
{ day: 'Mittwoch', hours: ['17:00 – 21:30'] },
{ day: 'Donnerstag', hours: ['17:00 – 21:30'] },
{ day: 'Freitag', hours: ['17:00 – 22:00'] },
{ day: 'Samstag', hours: ['17:00 – 22:00'] },
{ day: 'Sonntag', hours: ['11:30 – 13:30', '17:00 – 22:00'] },
]
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 (
@@ -39,47 +63,117 @@ function FlameIcon() {
function Home() {
const router = useRouter()
const { tables } = Route.useLoaderData()
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 [reservationTime, setReservationTime] = useState(() =>
formatDateTimeLocal(new Date(Date.now() + 60 * 60 * 1000)),
)
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 [success, setSuccess] = useState<string | null>(null)
const [confirmedReservation, setConfirmedReservation] = useState<ConfirmedReservation | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const todayName = new Date().toLocaleDateString('de-DE', { weekday: 'long' })
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)
setSuccess(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 {
await createReservation({
const created = await createReservation({
data: {
tableId: Number(tableId),
customerName,
customerEmail,
customerPhone,
message,
partySize: Number(partySize),
reservationTime: new Date(reservationTime).toISOString(),
},
})
setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.')
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')
@@ -95,11 +189,11 @@ function Home() {
<section className="hero" id="top">
<FlameIcon />
<h1>
Steakhaus <span>bei Mirko</span>
Vom Feuer verwöhnt, <span>mit Liebe serviert</span>
</h1>
<p>
Steaks vom Grill, gute Gesellschaft und ein gemütlicher Abend mitten in Meppen. Reservieren
Sie Ihren Tisch – wir freuen uns auf Sie.
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">
@@ -155,6 +249,36 @@ function Home() {
</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
@@ -178,10 +302,24 @@ function Home() {
</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}
@@ -189,24 +327,58 @@ function Home() {
required
/>
</label>
<label>
Datum &amp; Uhrzeit
<input
type="datetime-local"
value={reservationTime}
onChange={(e) => setReservationTime(e.target.value)}
required
/>
</label>
</div>
<button type="submit" disabled={isSubmitting}>
<label>
Datum &amp; 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>}
{success && <p className="message message-success">{success}</p>}
</form>
</div>
</section>
@@ -240,6 +412,20 @@ function Home() {
</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)}
/>
)}
</>
)
}