'use client';
import { useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { NumberInput } from '@/components/ui/number-input';
import { Switch } from '@/components/ui/switch';
import { Calendar, DateInput, formatDate } from '@/components/ui/date-input';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { ChipGroup, DonePanel, ErrorSummary, FlowHead, isEmail, Unfold, useFocusFirstError, type DoneStep, summaryOf, useFormState } from '@/components/ui/form-flow';
/*
* A request to book something that needs a person to confirm: a room for an
* event, a holiday home on flexible dates, a package with extras, a workshop
* on one of several dates. Unlike instant booking, it collects what the host
* needs to make an offer. Sends everything to `onSubmit`.
*
* event-venue kind of event, date, guests, and the rooms that fit fill in live
* flex-dates exact dates on a calendar or a flexible week, guests with children's ages
* packages a package, extras and people, the total counts along
* date-options up to three proposed dates, the format and the group
*/
export type BookingRequestVariant = 'event-venue' | 'flex-dates' | 'packages' | 'date-options';
export interface BookingSpace {
name: string;
/** Most guests it holds */
capacity: number;
note?: string;
}
export interface BookingPackage {
value: string;
name: string;
/** Price per person or per booking, in whole units */
price: number;
perPerson?: boolean;
text?: string;
includes?: string[];
}
export interface BookingExtra {
value: string;
label: string;
price?: number;
perPerson?: boolean;
icon?: IconName;
}
export interface BookingRequestProps {
variant?: BookingRequestVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** event-venue: kinds of event to pick */
kinds?: { value: string; label: string; icon?: IconName }[];
/** event-venue: the rooms and their capacity */
spaces?: BookingSpace[];
/** packages: the packages */
packages?: BookingPackage[];
/** event-venue, packages: extras to add */
extras?: BookingExtra[];
/** flex-dates: the months to offer for flexible dates */
months?: string[];
/** date-options: formats, e.g. at our place, at yours, online */
formats?: { value: string; label: string; icon?: IconName }[];
/** Currency sign, e.g. € or $ */
currency?: string;
/** The day the calendar treats as today, ISO, for stable server rendering */
today?: string;
/** Guests or people to start with */
guests?: number;
minGuests?: number;
maxGuests?: number;
steps?: DoneStep[];
submitLabel?: string;
done?: { title: string; text?: string };
onSubmit?: (data: Record<string, unknown>) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
kind: 'What are you celebrating?',
kindNeeded: 'Please choose the kind of event, it decides the setup of the room.',
date: 'Date',
dateNeeded: 'Please choose a date, so we can check the rooms for you.',
flexible: 'My date is flexible',
flexibleText: 'We suggest nearby dates if this one is taken',
guests: 'Guests',
rooms: 'Rooms for {n} guests',
fits: 'Fits',
tooSmall: 'Too small',
upTo: 'up to {n}',
extras: 'Add to it',
name: 'Name',
nameNeeded: 'Please tell us your name so we know who we write to.',
email: 'Email',
emailNeeded: 'This address looks incomplete. Please use a form like name@example.com so our offer reaches you.',
message: 'Anything else we should know?',
send: 'Send request',
when: 'When do you want to come?',
exact: 'Exact dates',
flex: 'I am flexible',
arrival: 'Arrival',
departure: 'Departure',
datesNeeded: 'Please choose arrival and departure, so we can check the house for you.',
nights: '{n} nights',
notChosen: 'Not chosen yet',
fromPrice: 'from {p}',
pickArrival: 'Choose your arrival',
pickDeparture: 'Now choose your departure',
month: 'Which month?',
monthNeeded: 'Please choose at least one month, so we can suggest dates.',
length: 'How long?',
adults: 'Adults',
children: 'Children',
childAge: 'Age of child {n}',
childAgeHint: 'For beds and prices',
package: 'Package',
packageNeeded: 'Please choose a package, so we can prepare the right offer.',
people: 'People',
perPerson: 'per person',
total: 'Estimated total',
totalNote: 'Final price in the offer, nothing is charged now',
includes: 'Includes',
preferred: 'Preferred dates',
preferredHint: 'Up to three, we confirm the first one that works',
option: 'Option {n}',
addDate: 'Add another date',
removeDate: 'Remove option {n}',
optionsNeeded: 'Please choose at least one date, so we can check the trainer.',
format: 'Where should it happen?',
formatNeeded: 'Please choose where it happens, it changes travel and price.',
participants: 'Participants',
company: 'Company',
companyNeeded: 'Please add your company, the invoice goes to it.',
doneTitle: 'Thank you, your request is with us',
doneText: 'We check availability and send you an offer, usually within one working day.',
again: 'Start a new request',
},
de: {
kind: 'Was feierst du?',
kindNeeded: 'Bitte wähle die Art der Feier, sie bestimmt den Aufbau des Raums.',
date: 'Datum',
dateNeeded: 'Bitte wähle ein Datum, damit wir die Räume für dich prüfen können.',
flexible: 'Mein Datum ist flexibel',
flexibleText: 'Ist es belegt, schlagen wir Tage in der Nähe vor',
guests: 'Gäste',
rooms: 'Räume für {n} Gäste',
fits: 'Passt',
tooSmall: 'Zu klein',
upTo: 'bis {n}',
extras: 'Dazu buchen',
name: 'Name',
nameNeeded: 'Bitte nenne uns deinen Namen, damit wir wissen, wem wir schreiben.',
email: 'E-Mail',
emailNeeded: 'Diese Adresse sieht unvollständig aus. Bitte nutze die Form name@beispiel.de, damit unser Angebot dich erreicht.',
message: 'Sollen wir noch etwas wissen?',
send: 'Anfrage senden',
when: 'Wann möchtest du kommen?',
exact: 'Feste Daten',
flex: 'Ich bin flexibel',
arrival: 'Anreise',
departure: 'Abreise',
datesNeeded: 'Bitte wähle Anreise und Abreise, damit wir das Haus für dich prüfen können.',
nights: '{n} Nächte',
notChosen: 'Noch offen',
fromPrice: 'ab {p}',
pickArrival: 'Wähle deine Anreise',
pickDeparture: 'Jetzt die Abreise wählen',
month: 'Welcher Monat?',
monthNeeded: 'Bitte wähle mindestens einen Monat, damit wir Termine vorschlagen können.',
length: 'Wie lange?',
adults: 'Erwachsene',
children: 'Kinder',
childAge: 'Alter von Kind {n}',
childAgeHint: 'Für Betten und Preise',
package: 'Paket',
packageNeeded: 'Bitte wähle ein Paket, damit wir das passende Angebot vorbereiten.',
people: 'Personen',
perPerson: 'pro Person',
total: 'Voraussichtlich gesamt',
totalNote: 'Endpreis im Angebot, jetzt wird nichts berechnet',
includes: 'Enthalten',
preferred: 'Wunschtermine',
preferredHint: 'Bis zu drei, wir bestätigen den ersten, der passt',
option: 'Termin {n}',
addDate: 'Weiteren Termin hinzufügen',
removeDate: 'Termin {n} entfernen',
optionsNeeded: 'Bitte wähle mindestens einen Termin, damit wir die Trainerin einplanen können.',
format: 'Wo soll es stattfinden?',
formatNeeded: 'Bitte wähle den Ort, er ändert Anreise und Preis.',
participants: 'Teilnehmende',
company: 'Firma',
companyNeeded: 'Bitte gib deine Firma an, die Rechnung geht an sie.',
doneTitle: 'Danke, deine Anfrage ist bei uns',
doneText: 'Wir prüfen die Verfügbarkeit und schicken dir ein Angebot, meist innerhalb eines Werktags.',
again: 'Neue Anfrage starten',
},
};
type Texts = (typeof TEXTS)['en'];
const money = (n: number, c: string, locale: 'en' | 'de') => (locale === 'de' ? `${n.toLocaleString('de-DE')} ${c}` : `${c}${n.toLocaleString('en-US')}`);
/** Name, email and an optional message, shared by every variant */
function Contact({ id, t, f, who, setWho, cols = 2 }: { id: string; t: Texts; f: ReturnType<typeof useFormState>; who: { name: string; email: string; message: string }; setWho: (w: { name: string; email: string; message: string }) => void; cols?: 1 | 2 }) {
return (
<div className={cn('grid grid-cols-1 gap-5', cols === 2 && 'sm:grid-cols-2')}>
<Input id={`${id}-name`} label={t.name} required autoComplete="name" value={who.name} onChange={(e) => (setWho({ ...who, name: e.target.value }), f.clear('name'))} error={f.errors.name} />
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={who.email} onChange={(e) => (setWho({ ...who, email: e.target.value }), f.clear('email'))} error={f.errors.email} />
<Textarea label={t.message} optional value={who.message} onChange={(e) => setWho({ ...who, message: e.target.value })} minRows={2} maxLength={800} className={cols === 2 ? 'sm:col-span-2' : undefined} />
</div>
);
}
function Done({ p, t, onAgain, className }: { p: BookingRequestProps; t: Texts; onAgain: () => void; className?: string }) {
return (
<div className={cn('kit-card mx-auto p-6 sm:p-10', className)}>
<DonePanel
title={p.done?.title ?? t.doneTitle}
text={p.done?.text ?? t.doneText}
steps={p.steps}
actions={
<Button variant="outline" iconLeft="rotate-ccw" onClick={onAgain}>
{t.again}
</Button>
}
/>
</div>
);
}
const contactCheck = (t: Texts, who: { name: string; email: string }, found: Record<string, string>) => {
if (!who.name.trim()) found.name = t.nameNeeded;
if (!isEmail(who.email)) found.email = t.emailNeeded;
return found;
};
/* ------------------------------------------------------------------ */
/* event-venue */
/* ------------------------------------------------------------------ */
function EventVenue({ p, t }: { p: BookingRequestProps; t: Texts }) {
const id = useId();
const f = useFormState();
const [kind, setKind] = useState<string[]>([]);
const [date, setDate] = useState<string | null>(null);
const [flexible, setFlexible] = useState(false);
const [guests, setGuests] = useState(p.guests ?? 60);
const [extras, setExtras] = useState<string[]>([]);
const [who, setWho] = useState({ name: '', email: '', message: '' });
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, f.attempt, summaryRef);
const spaces = p.spaces ?? [];
const biggest = Math.max(1, ...spaces.map((s) => s.capacity));
const summary = summaryOf(f.errors, [
['kind', `${id}-kind`, t.kind],
['date', `${id}-date`, t.date],
['name', `${id}-name`, t.name],
['email', `${id}-email`, t.email],
]);
if (f.done) return <Done p={p} t={t} onAgain={() => (setKind([]), setDate(null), f.setDone(false))} className="max-w-[48rem]" />;
return (
<Reveal effect="rise" className="mx-auto max-w-[64rem]">
<form
ref={formRef}
noValidate
className="kit-card grid grid-cols-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_20rem]"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!kind.length) found.kind = t.kindNeeded;
if (!date) found.date = t.dateNeeded;
if (f.fail(contactCheck(t, who, found))) return;
p.onSubmit?.({ kind: kind[0], date, flexible, guests, extras, ...who });
f.setDone(true);
}}
>
<div className="grid min-w-0 gap-6 p-6 sm:p-8">
<ErrorSummary ref={summaryRef} errors={summary} />
<ChipGroup id={`${id}-kind`} legend={t.kind} required options={(p.kinds ?? []).map((k) => ({ value: k.value, label: k.label, icon: k.icon }))} value={kind} onChange={(v) => (setKind(v), f.clear('kind'))} error={f.errors.kind} />
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<DateInput id={`${id}-date`} label={t.date} required today={p.today} min={p.today} value={date} onValueChange={(v) => (setDate(v), f.clear('date'))} error={f.errors.date} />
<NumberInput label={t.guests} variant="stepper" min={p.minGuests ?? 10} max={p.maxGuests ?? 300} step={5} value={guests} onValueChange={setGuests} />
{/* Phones and tablets: the rooms answer right under the guests, the full list follows the form */}
<p aria-hidden className="flex flex-wrap items-center gap-1.5 text-[13px] text-muted-foreground sm:col-span-2 lg:hidden">
{spaces.map((s) => (
<span key={s.name} className={cn('inline-flex items-center gap-1 rounded-full border px-2.5 py-1 transition-colors duration-300', s.capacity >= guests ? 'border-[color-mix(in_oklab,var(--success)_40%,var(--border))] text-foreground' : 'border-border text-subtle-foreground line-through')}>
{s.capacity >= guests && <Icon name="check" size={12} strokeWidth={2.4} className="text-success" />}
{s.name}
</span>
))}
</p>
<Switch label={t.flexible} description={t.flexibleText} checked={flexible} onCheckedChange={setFlexible} className="sm:col-span-2" />
</div>
{p.extras && p.extras.length > 0 && <ChipGroup legend={t.extras} multiple options={p.extras.map((x) => ({ value: x.value, label: x.label, icon: x.icon }))} value={extras} onChange={setExtras} />}
<div className="border-t border-border pt-6">
<Contact id={id} t={t} f={f} who={who} setWho={setWho} />
</div>
<div className="flex justify-end">
<Button type="submit" icon="send" magnetic className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</div>
{/* The rooms with a bar per capacity, each answers live whether it fits the guests */}
<aside className="border-t border-border bg-surface p-6 sm:p-8 lg:border-s lg:border-t-0">
<p aria-live="polite" className="text-[14px] font-semibold text-foreground">{t.rooms.replace('{n}', String(guests))}</p>
<Stagger step={90} as="ul" className="mt-5 grid gap-4">
{spaces.map((s) => {
const fits = s.capacity >= guests;
const share = Math.min(1, guests / s.capacity);
return (
<li key={s.name} className={cn('rounded-xl border bg-card p-4 transition-[border-color,opacity] duration-500', fits ? 'border-border' : 'border-border opacity-60')}>
<div className="flex items-baseline justify-between gap-3">
<span className="text-[14px] font-medium text-foreground">{s.name}</span>
<span className={cn('inline-flex items-center gap-1 text-[12px] font-medium transition-colors', fits ? 'text-success-foreground' : 'text-muted-foreground')}>
<Icon name={fits ? 'check' : 'minus'} size={13} strokeWidth={2.2} />
{fits ? t.fits : t.tooSmall}
</span>
</div>
<p className="mt-0.5 text-[12.5px] text-muted-foreground">
{t.upTo.replace('{n}', String(s.capacity))}
{s.note ? ` · ${s.note}` : ''}
</p>
{/* Bar length is the room size, the fill is how full it would be */}
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)]" style={{ width: `${round((s.capacity / biggest) * 100, 2)}%` }}>
<div className={cn('h-full origin-left rounded-full transition-[transform,background-color] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', fits ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--destructive)_70%,var(--muted))]')} style={{ transform: `scaleX(${round(share, 3)})` }} />
</div>
</li>
);
})}
</Stagger>
</aside>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* flex-dates */
/* ------------------------------------------------------------------ */
function FlexDates({ p, t }: { p: BookingRequestProps; t: Texts }) {
const id = useId();
const locale = useLocale();
const f = useFormState();
const [mode, setMode] = useState<'exact' | 'flex'>('exact');
const [range, setRange] = useState<[string | null, string | null]>([null, null]);
const [months, setMonths] = useState<string[]>([]);
const [length, setLength] = useState<string[]>([]);
const [adults, setAdults] = useState(2);
const [children, setChildren] = useState(0);
const [ages, setAges] = useState<string[]>([]);
const [who, setWho] = useState({ name: '', email: '', message: '' });
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, f.attempt, summaryRef);
const nights = range[0] && range[1] ? Math.round((Date.parse(range[1]) - Date.parse(range[0])) / 86400000) : 0;
const lengths = locale === 'de' ? ['Wochenende', 'Eine Woche', 'Zwei Wochen'] : ['A weekend', 'One week', 'Two weeks'];
const summary = summaryOf(f.errors, [
['dates', mode === 'exact' ? `${id}-cal` : `${id}-months`, mode === 'exact' ? `${t.arrival} · ${t.departure}` : t.month],
['name', `${id}-name`, t.name],
['email', `${id}-email`, t.email],
]);
if (f.done) return <Done p={p} t={t} onAgain={() => (setRange([null, null]), setMonths([]), f.setDone(false))} className="max-w-[48rem]" />;
return (
<Reveal effect="rise" className="mx-auto max-w-[58rem]">
<form
ref={formRef}
noValidate
className="kit-card grid gap-7 p-6 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (mode === 'exact' && !(range[0] && range[1])) found.dates = t.datesNeeded;
if (mode === 'flex' && !months.length) found.dates = t.monthNeeded;
if (f.fail(contactCheck(t, who, found))) return;
p.onSubmit?.({ mode, range, months, length: length[0], adults, children, ages, ...who });
f.setDone(true);
}}
>
<ErrorSummary ref={summaryRef} errors={summary} />
{/* Exact or flexible, a switch with a sliding tile */}
<div role="radiogroup" aria-label={t.when} className="relative grid grid-cols-2 rounded-xl bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] p-1 sm:max-w-[26rem]">
<span aria-hidden className={cn('absolute inset-y-1 start-1 w-[calc(50%-0.25rem)] rounded-lg bg-card shadow-[var(--shadow-sm)] transition-[translate] duration-[420ms] ease-[cubic-bezier(0.3,1.25,0.5,1)] motion-reduce:transition-none', mode === 'flex' && 'translate-x-full rtl:-translate-x-full')} />
{(['exact', 'flex'] as const).map((m) => (
<label key={m} htmlFor={`${id}-${m}`} className="relative z-10 flex min-h-11 cursor-pointer items-center justify-center gap-2 rounded-lg text-[14px] font-medium text-muted-foreground transition-colors has-[:checked]:text-foreground has-[:focus-visible]:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)]">
<input id={`${id}-${m}`} type="radio" name={`${id}-mode`} checked={mode === m} onChange={() => (setMode(m), f.clear('dates'))} className="sr-only" />
<Icon name={m === 'exact' ? 'calendar' : 'calendar-check'} size={16} />
{m === 'exact' ? t.exact : t.flex}
</label>
))}
</div>
<div key={mode} className="kit-rise">
{mode === 'exact' ? (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[auto_minmax(0,1fr)] lg:items-start">
<div id={`${id}-cal`} role="group" aria-label={`${t.arrival} · ${t.departure}`} aria-describedby={f.errors.dates ? `${id}-cal-err` : undefined} className={cn('rounded-xl border p-3 sm:p-4', f.errors.dates ? 'border-[color-mix(in_oklab,var(--destructive)_50%,var(--border))]' : 'border-border')}>
<Calendar mode="range" months={2} today={p.today} min={p.today} month={p.today} range={range} onRangeChange={(r) => (setRange(r), f.clear('dates'))} />
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-1">
{[
[t.arrival, range[0]],
[t.departure, range[1]],
].map(([label, v]) => (
<div key={label as string} className="rounded-xl border border-border bg-surface px-4 py-3">
<p className="text-[12px] text-muted-foreground">{label}</p>
<p className={cn('mt-0.5 tabular-nums', v ? 'text-[15px] font-semibold text-foreground' : 'text-[14px] text-subtle-foreground')}>{v ? formatDate(v as string, locale) : t.notChosen}</p>
</div>
))}
<p aria-live="polite" className="col-span-2 text-[13px] text-muted-foreground lg:col-span-1">
{nights > 0 ? t.nights.replace('{n}', String(nights)) : range[0] ? t.pickDeparture : t.pickArrival}
</p>
{f.errors.dates && (
<p id={`${id}-cal-err`} className="col-span-2 flex items-start gap-1.5 text-[13px] font-medium text-destructive lg:col-span-1">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
{f.errors.dates}
</p>
)}
</div>
</div>
) : (
<div className="grid gap-6">
<ChipGroup id={`${id}-months`} legend={t.month} required multiple options={(p.months ?? []).map((m) => ({ value: m, label: m }))} value={months} onChange={(v) => (setMonths(v), f.clear('dates'))} error={f.errors.dates} layout={4} />
<ChipGroup legend={t.length} options={lengths.map((x) => ({ value: x, label: x }))} value={length} onChange={setLength} />
</div>
)}
</div>
<div className="border-t border-border pt-6">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<NumberInput label={t.adults} variant="stepper" min={1} max={12} value={adults} onValueChange={setAdults} />
<NumberInput
label={t.children}
variant="stepper"
min={0}
max={6}
value={children}
onValueChange={(n) => {
setChildren(n);
setAges((a) => (n > a.length ? [...a, ...Array(n - a.length).fill('')] : a.slice(0, n)));
}}
/>
</div>
{/* One age field per child, each rises in as it is added; closed, it takes no room at all */}
<Unfold open={children > 0}>
<div className="grid grid-cols-2 gap-3 pt-5 sm:grid-cols-3">
{ages.map((a, i) => (
<Input key={i} type="number" inputMode="numeric" min={0} max={17} label={t.childAge.replace('{n}', String(i + 1))} value={a} onChange={(e) => setAges((x) => x.map((y, j) => (j === i ? e.target.value : y)))} className="kit-rise" />
))}
</div>
<p className="mt-2 text-[13px] text-muted-foreground">{t.childAgeHint}</p>
</Unfold>
</div>
<div className="border-t border-border pt-6">
<Contact id={id} t={t} f={f} who={who} setWho={setWho} />
</div>
<div className="flex justify-end">
<Button type="submit" icon="send" className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* packages */
/* ------------------------------------------------------------------ */
function Packages({ p, t }: { p: BookingRequestProps; t: Texts }) {
const id = useId();
const locale = useLocale();
const f = useFormState();
const c = p.currency ?? '€';
const pkgs = p.packages ?? [];
const [pkg, setPkg] = useState<string>('');
const [extras, setExtras] = useState<string[]>([]);
const [people, setPeople] = useState(p.guests ?? 2);
const [date, setDate] = useState<string | null>(null);
const [who, setWho] = useState({ name: '', email: '', message: '' });
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, f.attempt, summaryRef);
const chosen = pkgs.find((x) => x.value === pkg);
const extraSum = (p.extras ?? []).filter((x) => extras.includes(x.value)).reduce((s, x) => s + (x.price ?? 0) * (x.perPerson ? people : 1), 0);
const total = (chosen ? chosen.price * (chosen.perPerson ? people : 1) : 0) + extraSum;
const summary = summaryOf(f.errors, [
['pkg', `${id}-pkg-0`, t.package],
['name', `${id}-name`, t.name],
['email', `${id}-email`, t.email],
]);
if (f.done) return <Done p={p} t={t} onAgain={() => (setPkg(''), setExtras([]), f.setDone(false))} className="max-w-[48rem]" />;
return (
<form
ref={formRef}
noValidate
className="mx-auto grid max-w-[68rem] grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] lg:items-start lg:gap-8"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!pkg) found.pkg = t.packageNeeded;
if (f.fail(contactCheck(t, who, found))) return;
p.onSubmit?.({ package: pkg, extras, people, date, total, ...who });
f.setDone(true);
}}
>
<div className="grid min-w-0 gap-6">
<ErrorSummary ref={summaryRef} errors={summary} />
<fieldset className="min-w-0" aria-describedby={f.errors.pkg ? `${id}-pkg-err` : undefined}>
<legend className="text-[15px] font-semibold text-foreground">{t.package}</legend>
<Stagger step={90} as="div" className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
{pkgs.map((x, i) => {
const on = pkg === x.value;
return (
<label key={x.value} htmlFor={`${id}-pkg-${i}`} className={cn('group/pkg relative flex h-full cursor-pointer flex-col rounded-2xl border bg-card p-5 shadow-[var(--shadow-sm)] transition-[border-color,box-shadow,transform] duration-300 hover:-translate-y-0.5 motion-reduce:hover:translate-y-0', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_55%,var(--border)),var(--shadow-md)]' : f.errors.pkg ? 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]' : 'border-border hover:border-border-strong', 'has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]')}>
<input id={`${id}-pkg-${i}`} type="radio" name={`${id}-pkg`} value={x.value} checked={on} onChange={() => (setPkg(x.value), f.clear('pkg'))} aria-invalid={f.errors.pkg ? true : undefined} className="sr-only" />
<span className="flex items-start justify-between gap-3">
<span className="text-[16px] font-semibold text-foreground">{x.name}</span>
<span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))]')}>
<span className={cn('size-2 rounded-full bg-primary-foreground transition-[scale] duration-300 ease-[cubic-bezier(0.34,1.7,0.64,1)]', on ? 'scale-100' : 'scale-0')} />
</span>
</span>
<span className="mt-2 text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">
{money(x.price, c, locale)}
{x.perPerson && <span className="ms-1.5 text-[13px] font-normal tracking-normal text-muted-foreground">{t.perPerson}</span>}
</span>
{x.text && <span className="mt-2 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{x.text}</span>}
{x.includes && x.includes.length > 0 && (
<ul className="mt-4 grid gap-1.5 border-t border-border pt-4">
{x.includes.map((inc) => (
<li key={inc} className="flex items-start gap-2 text-[13px] text-foreground">
<Icon name="check" size={14} className="mt-0.5 shrink-0 text-success" />
{inc}
</li>
))}
</ul>
)}
</label>
);
})}
</Stagger>
{f.errors.pkg && (
<p id={`${id}-pkg-err`} className="kit-rise mt-3 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
{f.errors.pkg}
</p>
)}
</fieldset>
<div className="kit-card grid gap-6 p-6 sm:p-8">
{p.extras && p.extras.length > 0 && (
<ChipGroup legend={t.extras} multiple options={p.extras.map((x) => ({ value: x.value, label: x.label, meta: x.price ? `+${money(x.price, c, locale)}${x.perPerson ? ` ${t.perPerson}` : ''}` : undefined, icon: x.icon }))} value={extras} onChange={setExtras} />
)}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<NumberInput label={t.people} variant="stepper" min={p.minGuests ?? 1} max={p.maxGuests ?? 12} value={people} onValueChange={setPeople} />
<DateInput label={t.date} optional today={p.today} min={p.today} value={date} onValueChange={setDate} />
</div>
<div className="border-t border-border pt-6">
<Contact id={id} t={t} f={f} who={who} setWho={setWho} />
</div>
</div>
</div>
{/* The total stays in view on desktop and sits above the button on phones */}
<aside className="kit-card p-6 lg:sticky lg:top-24 lg:mt-9">
<p className="text-[13px] text-muted-foreground">{t.total}</p>
<p aria-live="polite" className="mt-1 text-[32px] font-semibold tabular-nums tracking-[-0.03em] text-foreground">
<span key={total} className="inline-block motion-safe:animate-[kit-rise_0.4s_var(--ease-kit)]">
{chosen ? money(total, c, locale) : t.fromPrice.replace('{p}', money(Math.min(...pkgs.map((x) => x.price)), c, locale))}
</span>
</p>
<dl className="mt-4 grid gap-2 border-t border-border pt-4 text-[13.5px]">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">{chosen?.name ?? t.package}</dt>
<dd className={cn('tabular-nums', chosen ? 'text-foreground' : 'text-subtle-foreground')}>{chosen ? money(chosen.price * (chosen.perPerson ? people : 1), c, locale) : t.notChosen}</dd>
</div>
{(p.extras ?? [])
.filter((x) => extras.includes(x.value))
.map((x) => (
<div key={x.value} className="kit-rise flex justify-between gap-3">
<dt className="text-muted-foreground">{x.label}</dt>
<dd className="tabular-nums text-foreground">{money((x.price ?? 0) * (x.perPerson ? people : 1), c, locale)}</dd>
</div>
))}
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">{t.people}</dt>
<dd className="tabular-nums text-foreground">{people}</dd>
</div>
</dl>
<Button type="submit" icon="send" className="mt-6 w-full">
{p.submitLabel ?? t.send}
</Button>
<p className="mt-3 text-center text-[12.5px] text-muted-foreground">{t.totalNote}</p>
</aside>
</form>
);
}
/* ------------------------------------------------------------------ */
/* date-options */
/* ------------------------------------------------------------------ */
function DateOptions({ p, t }: { p: BookingRequestProps; t: Texts }) {
const id = useId();
const f = useFormState();
const [dates, setDates] = useState<(string | null)[]>([null]);
const [format, setFormat] = useState<string[]>([]);
const [people, setPeople] = useState(p.guests ?? 12);
const [company, setCompany] = useState('');
const [who, setWho] = useState({ name: '', email: '', message: '' });
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, f.attempt, summaryRef);
const summary = summaryOf(f.errors, [
['dates', `${id}-d0`, t.preferred],
['format', `${id}-format`, t.format],
['company', `${id}-company`, t.company],
['name', `${id}-name`, t.name],
['email', `${id}-email`, t.email],
]);
if (f.done) return <Done p={p} t={t} onAgain={() => (setDates([null]), setFormat([]), f.setDone(false))} className="max-w-[48rem]" />;
return (
<Reveal effect="rise" className="mx-auto max-w-[52rem]">
<form
ref={formRef}
noValidate
className="kit-card grid gap-7 p-6 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!dates.some(Boolean)) found.dates = t.optionsNeeded;
if (!format.length) found.format = t.formatNeeded;
if (!company.trim()) found.company = t.companyNeeded;
if (f.fail(contactCheck(t, who, found))) return;
p.onSubmit?.({ dates: dates.filter(Boolean), format: format[0], people, company, ...who });
f.setDone(true);
}}
>
<ErrorSummary ref={summaryRef} errors={summary} />
<fieldset className="min-w-0">
<legend className="text-[15px] font-semibold text-foreground">
{t.preferred}
<span aria-hidden className="ms-0.5 text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]">
*
</span>
</legend>
<p className="mt-1 text-[13px] text-muted-foreground">{t.preferredHint}</p>
{/* Options on a numbered rail, each new one rises in */}
<ol className="relative mt-5 grid gap-4 ps-11">
{dates.length > 1 && <span aria-hidden className="absolute bottom-[22px] start-[15px] top-[50px] w-px bg-border" />}
{dates.map((d, i) => (
<li key={i} className="kit-rise relative">
<span aria-hidden className={cn('absolute -start-11 top-[34px] grid size-8 place-items-center rounded-full border text-[13px] font-semibold tabular-nums transition-colors', d ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-card text-muted-foreground')}>
{d ? <Icon name="check" size={15} strokeWidth={2.4} /> : i + 1}
</span>
<div className="flex items-end gap-2">
<DateInput
id={`${id}-d${i}`}
label={t.option.replace('{n}', String(i + 1))}
today={p.today}
min={p.today}
value={d}
error={i === 0 ? f.errors.dates : undefined}
onValueChange={(v) => {
setDates((x) => x.map((y, j) => (j === i ? v : y)));
f.clear('dates');
}}
className="min-w-0 flex-1"
/>
{dates.length > 1 && (
<button type="button" aria-label={t.removeDate.replace('{n}', String(i + 1))} onClick={() => setDates((x) => x.filter((_, j) => j !== i))} className="grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:text-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none">
<Icon name="trash" size={17} />
</button>
)}
</div>
</li>
))}
</ol>
{dates.length < 3 && (
<Button type="button" variant="ghost" size="sm" iconLeft="plus" className="ms-[1.875rem] mt-3" onClick={() => setDates((x) => [...x, null])}>
{t.addDate}
</Button>
)}
</fieldset>
<div className="grid grid-cols-1 gap-6 border-t border-border pt-6">
<ChipGroup id={`${id}-format`} legend={t.format} required options={(p.formats ?? []).map((x) => ({ value: x.value, label: x.label, icon: x.icon }))} value={format} onChange={(v) => (setFormat(v), f.clear('format'))} error={f.errors.format} layout={(p.formats?.length ?? 0) === 3 ? 3 : 'wrap'} />
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<NumberInput label={t.participants} variant="stepper" min={p.minGuests ?? 4} max={p.maxGuests ?? 40} value={people} onValueChange={setPeople} />
<Input id={`${id}-company`} label={t.company} required autoComplete="organization" value={company} onChange={(e) => (setCompany(e.target.value), f.clear('company'))} error={f.errors.company} />
</div>
</div>
<div className="border-t border-border pt-6">
<Contact id={id} t={t} f={f} who={who} setWho={setWho} />
</div>
<div className="flex justify-end">
<Button type="submit" icon="send" className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
/**
* A request to book something a person confirms.
* <BookingRequest variant="event-venue" kinds={[…]} spaces={[{ name, capacity }]} today="2026-10-21" />
* <BookingRequest variant="packages" packages={[{ value, name, price, perPerson }]} extras={[…]} currency="€" />
*/
export function BookingRequest(props: BookingRequestProps) {
const { variant = 'event-venue', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
return (
<Section className={className}>
<FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />
{variant === 'event-venue' && <EventVenue p={props} t={t} />}
{variant === 'flex-dates' && <FlexDates p={props} t={t} />}
{variant === 'packages' && <Packages p={props} t={t} />}
{variant === 'date-options' && <DateOptions p={props} t={t} />}
</Section>
);
}