Skip to content
UI Kit
Accent color

Booking request

Requests for bookings a person confirms, unlike instant appointments. A room for an event with live capacity, a holiday home on exact or flexible dates, a package with a total that counts along, a workshop on one of three dates.

One of 4 components in Request forms

4 variants

Variants

Event venue

Kind of event, date, guests and extras. Beside the form the rooms answer live whether they fit, each bar is as long as the room is big and fills with the guests.

Open in Studio

Flexible dates

Exact dates on a two month calendar with the nights counted, or flexible on months and a length. Adding a child opens a field for its age, each one rises in.

Open in Studio

Packages

Package cards rise in one after another, the chosen one holds an accent ring. Extras show their price, and a total beside the form counts along with every choice and every person.

Open in Studio

Proposed dates

Up to three proposed dates on a numbered rail, each filled one turns into a check. Then the format, the group size and the company for the invoice.

Open in Studio

Details

What Booking request brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/booking-request.tsx
Brings along
utilskitbuttoniconrevealsectionmotionform-flowinputtextareanumber-inputswitchdate-input
npm packages
None
Languages
English and German, 57 texts of its own

Props

The BookingRequestProps interface from components/ui/booking-request.tsx, with the comments from the code.

  • variantoptional
    BookingRequestVariant

    event-venueflex-datespackagesdate-options

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • kindsoptional
    { value: string; label: string; icon?: IconName }[]

    event-venue: kinds of event to pick

  • spacesoptional
    BookingSpace[]

    event-venue: the rooms and their capacity

  • packagesoptional
    BookingPackage[]

    packages: the packages

  • extrasoptional
    BookingExtra[]

    event-venue, packages: extras to add

  • monthsoptional
    string[]

    flex-dates: the months to offer for flexible dates

  • formatsoptional
    { value: string; label: string; icon?: IconName }[]

    date-options: formats, e.g. at our place, at yours, online

  • currencyoptional
    string

    Currency sign, e.g. € or $

  • todayoptional
    string

    The day the calendar treats as today, ISO, for stable server rendering

  • guestsoptional
    number

    Guests or people to start with

  • minGuestsoptional
    number
  • maxGuestsoptional
    number
  • stepsoptional
    DoneStep[]
  • submitLabeloptional
    string
  • doneoptional
    { title: string; text?: string }
  • onSubmitoptional
    (data: Record<string, unknown>) => void

Built-in texts

Booking request writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 57 texts
kindEnglish What are you celebrating?German Was feierst du?
kindNeededEnglish Please choose the kind of event, it decides the setup of the room.German Bitte wähle die Art der Feier, sie bestimmt den Aufbau des Raums.
dateEnglish DateGerman Datum
dateNeededEnglish Please choose a date, so we can check the rooms for you.German Bitte wähle ein Datum, damit wir die Räume für dich prüfen können.
flexibleEnglish My date is flexibleGerman Mein Datum ist flexibel
flexibleTextEnglish We suggest nearby dates if this one is takenGerman Ist es belegt, schlagen wir Tage in der Nähe vor
guestsEnglish GuestsGerman Gäste
roomsEnglish Rooms for {n} guestsGerman Räume für {n} Gäste
fitsEnglish FitsGerman Passt
tooSmallEnglish Too smallGerman Zu klein
upToEnglish up to {n}German bis {n}
extrasEnglish Add to itGerman Dazu buchen
nameEnglish NameGerman Name
nameNeededEnglish Please tell us your name so we know who we write to.German Bitte nenne uns deinen Namen, damit wir wissen, wem wir schreiben.
emailEnglish EmailGerman E-Mail
emailNeededEnglish This address looks incomplete. Please use a form like name@example.com so our offer reaches you.German Diese Adresse sieht unvollständig aus. Bitte nutze die Form name@beispiel.de, damit unser Angebot dich erreicht.
messageEnglish Anything else we should know?German Sollen wir noch etwas wissen?
sendEnglish Send requestGerman Anfrage senden
whenEnglish When do you want to come?German Wann möchtest du kommen?
exactEnglish Exact datesGerman Feste Daten
flexEnglish I am flexibleGerman Ich bin flexibel
arrivalEnglish ArrivalGerman Anreise
departureEnglish DepartureGerman Abreise
datesNeededEnglish Please choose arrival and departure, so we can check the house for you.German Bitte wähle Anreise und Abreise, damit wir das Haus für dich prüfen können.
nightsEnglish {n} nightsGerman {n} Nächte
notChosenEnglish Not chosen yetGerman Noch offen
fromPriceEnglish from {p}German ab {p}
pickArrivalEnglish Choose your arrivalGerman Wähle deine Anreise
pickDepartureEnglish Now choose your departureGerman Jetzt die Abreise wählen
monthEnglish Which month?German Welcher Monat?
monthNeededEnglish Please choose at least one month, so we can suggest dates.German Bitte wähle mindestens einen Monat, damit wir Termine vorschlagen können.
lengthEnglish How long?German Wie lange?
adultsEnglish AdultsGerman Erwachsene
childrenEnglish ChildrenGerman Kinder
childAgeEnglish Age of child {n}German Alter von Kind {n}
childAgeHintEnglish For beds and pricesGerman Für Betten und Preise
packageEnglish PackageGerman Paket
packageNeededEnglish Please choose a package, so we can prepare the right offer.German Bitte wähle ein Paket, damit wir das passende Angebot vorbereiten.
peopleEnglish PeopleGerman Personen
perPersonEnglish per personGerman pro Person
totalEnglish Estimated totalGerman Voraussichtlich gesamt
totalNoteEnglish Final price in the offer, nothing is charged nowGerman Endpreis im Angebot, jetzt wird nichts berechnet
includesEnglish IncludesGerman Enthalten
preferredEnglish Preferred datesGerman Wunschtermine
preferredHintEnglish Up to three, we confirm the first one that worksGerman Bis zu drei, wir bestätigen den ersten, der passt
optionEnglish Option {n}German Termin {n}
addDateEnglish Add another dateGerman Weiteren Termin hinzufügen
removeDateEnglish Remove option {n}German Termin {n} entfernen
optionsNeededEnglish Please choose at least one date, so we can check the trainer.German Bitte wähle mindestens einen Termin, damit wir die Trainerin einplanen können.
formatEnglish Where should it happen?German Wo soll es stattfinden?
formatNeededEnglish Please choose where it happens, it changes travel and price.German Bitte wähle den Ort, er ändert Anreise und Preis.
participantsEnglish ParticipantsGerman Teilnehmende
companyEnglish CompanyGerman Firma
companyNeededEnglish Please add your company, the invoice goes to it.German Bitte gib deine Firma an, die Rechnung geht an sie.
doneTitleEnglish Thank you, your request is with usGerman Danke, deine Anfrage ist bei uns
doneTextEnglish We check availability and send you an offer, usually within one working day.German Wir prüfen die Verfügbarkeit und schicken dir ein Angebot, meist innerhalb eines Werktags.
againEnglish Start a new requestGerman Neue Anfrage starten

Accessibility

Read from the source of the component.

  • Sets aria-describedby, aria-invalid, aria-label and aria-live, so screen readers announce purpose and state.
  • Uses the roles group and radiogroup.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 735 lines
components/ui/booking-request.tsx
'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>
  );
}

Frequent questions

How do I install Booking request?

With aiw add @ai-website/booking-request. The command puts components/ui/booking-request.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section, motion, form-flow, input, textarea, number-input, switch and date-input. It needs no further npm packages.

Which variants does Booking request have?

4 variants, Event venue, Flexible dates, Packages and Proposed dates. You pick one with the variant prop, for example variant="flex-dates".

Can I translate the texts of Booking request?

Yes. Booking request carries 57 texts of its own in English and German, such as “What are you celebrating?”, “Please choose the kind of event, it decides the setup of the room.” and “Date”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Booking request?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Booking request with aiw_components and install it with aiw_add @ai-website/booking-request. Name the variant in your prompt, for example variant="event-venue", and the assistant writes your content straight into the props.

Can I adjust Booking request without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching BookingRequest code to copy.

Often searched

Jump to

Copied to the clipboard