← alle entries

booking-inquiry

FC-0001bookingblockv1.0.0

Boekingsaanvraag-formulier: periode, gasten, prijsopbouw (nachten + toeslag + borg) en gastgegevens; verstuurt via het onSubmit-contract van het project.

zelfde component — andere tokens

Reserveren

€ 120 / nacht

Maximaal 4 gasten.

€ 120 × 1 nacht
€ 120
Borg (eenmalig)
€ 100
Totaal
€ 220
Footnote-slot: bv. de aanbetalings- of annuleringsregel van het project.
blocks/booking/booking-inquiry.tsx
/**
 * BookingInquiry — boekingsaanvraag-blok, geoogst uit het Apoera-boekingssysteem.
 *
 * V6-grens: dit blok is UI + datacontract. Het verstuurt zelf NIETS — het
 * project levert `onSubmit(aanvraag)` (API, mail, wat dan ook) en gooit bij
 * fout; het blok toont dan de foutmelding. Valuta is een projectkeuze: geef
 * `formatPrice` mee. Domein-teksten (aanbetaling, borg-uitleg, contactkanaal)
 * zijn slots/props — geen klantdata in de entry.
 *
 * Prijsmodel (uit de oogstbron): nachten × prijs + extra personen boven
 * `includedGuests` × toeslag × nachten + eenmalige borg.
 */

"use client";

import { useEffect, useMemo, useState } from "react";
import type * as React from "react";
import { Button } from "../../primitives/ui/button";
import { Field } from "../../primitives/ui/input";
import type { DateRange } from "../../primitives/ui/date-range-calendar";

export interface BookingRequest {
  name: string;
  email: string;
  phone: string;
  guests: number;
  checkIn: string;
  checkOut: string;
  nights: number;
  total: number;
  notes: string;
}

export interface BookingInquiryProps {
  pricePerNight: number;
  extraPersonPrice?: number;
  /** Aantal gasten dat in de basisprijs zit; daarboven geldt de toeslag. */
  includedGuests?: number;
  deposit?: number;
  maxGuests?: number;
  minStay?: number;
  formatPrice?: (amount: number) => string;
  /** Het contract: het project verstuurt de aanvraag en gooit bij mislukking. */
  onSubmit: (aanvraag: BookingRequest) => Promise<void>;
  /** Tweerichtingskoppeling met een kalender (DateRangeCalendar). */
  dates?: DateRange;
  onDatesChange?: (dates: DateRange) => void;
  title?: string;
  submitLabel?: string;
  /** Onder de knop — bv. de aanbetalings- of annuleringsregel van het project. */
  footnote?: React.ReactNode;
  /** Extra inhoud in de bevestiging — bv. een contactlink van het project. */
  successExtra?: React.ReactNode;
}

const defaultFormat = (amount: number) => new Intl.NumberFormat("nl-NL").format(amount);
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const toIso = (d: Date): string =>
  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;

export function BookingInquiry({
  pricePerNight,
  extraPersonPrice = 0,
  includedGuests = 2,
  deposit = 0,
  maxGuests = 10,
  minStay = 1,
  formatPrice = defaultFormat,
  onSubmit,
  dates,
  onDatesChange,
  title = "Reserveren",
  submitLabel = "Verstuur aanvraag",
  footnote,
  successExtra,
}: BookingInquiryProps) {
  const todayIso = useMemo(() => toIso(new Date()), []);
  const tomorrowIso = useMemo(() => toIso(new Date(Date.now() + 86_400_000)), []);

  const [checkIn, setCheckIn] = useState(todayIso);
  const [checkOut, setCheckOut] = useState(tomorrowIso);
  const [guests, setGuests] = useState(Math.min(2, maxGuests));
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [phone, setPhone] = useState("");
  const [notes, setNotes] = useState("");
  const [errors, setErrors] = useState<Partial<Record<"name" | "email" | "phone" | "dates", string>>>({});
  const [busy, setBusy] = useState(false);
  const [submitFout, setSubmitFout] = useState<string | null>(null);
  const [klaar, setKlaar] = useState(false);

  /* Kalenderselectie → velden. */
  useEffect(() => {
    if (dates?.start) setCheckIn(dates.start);
    if (dates?.end) setCheckOut(dates.end);
  }, [dates]);

  /* Velden → kalender. */
  useEffect(() => {
    onDatesChange?.({ start: checkIn || null, end: checkOut || null });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [checkIn, checkOut]);

  const nights =
    checkIn && checkOut
      ? Math.max(0, Math.round((Date.parse(checkOut) - Date.parse(checkIn)) / 86_400_000))
      : 0;
  const extraGuests = Math.max(0, guests - includedGuests);
  const total = nights * pricePerNight + extraGuests * extraPersonPrice * nights + deposit;

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const next: typeof errors = {};
    if (name.trim().length < 2) next.name = "Naam is verplicht.";
    if (!EMAIL_RE.test(email)) next.email = "Dit is geen geldig e-mailadres.";
    if (phone.trim().length < 6) next.phone = "Telefoonnummer is verplicht.";
    if (nights < minStay) next.dates = `Minimale duur is ${minStay} nacht${minStay === 1 ? "" : "en"}.`;
    setErrors(next);
    if (Object.keys(next).length > 0) return;

    setBusy(true);
    setSubmitFout(null);
    try {
      await onSubmit({
        name: name.trim(), email: email.trim(), phone: phone.trim(),
        guests, checkIn, checkOut, nights, total, notes: notes.trim(),
      });
      setKlaar(true);
    } catch (err) {
      setSubmitFout(err instanceof Error ? err.message : "Versturen is niet gelukt. Probeer het opnieuw.");
    } finally {
      setBusy(false);
    }
  };

  if (klaar) {
    return (
      <div className="fp-booking fp-booking--success" role="status">
        <h3 className="fp-booking__title">Aanvraag ontvangen</h3>
        <p className="fp-booking__text">
          Je aanvraag voor {nights} nacht{nights === 1 ? "" : "en"} is verstuurd.
          Je ontvangt zo snel mogelijk een bevestiging op {email}.
        </p>
        {successExtra}
      </div>
    );
  }

  return (
    <form className="fp-booking" onSubmit={handleSubmit} noValidate>
      <div className="fp-booking__head">
        <h3 className="fp-booking__title">{title}</h3>
        <p className="fp-booking__price">
          <strong>{formatPrice(pricePerNight)}</strong> <span>/ nacht</span>
        </p>
      </div>

      <div className="fp-booking__dates">
        <Field label="Aankomst" type="date" min={todayIso} value={checkIn}
          onChange={(e) => setCheckIn(e.target.value)} />
        <Field label="Vertrek" type="date" min={checkIn || tomorrowIso} value={checkOut}
          onChange={(e) => setCheckOut(e.target.value)} error={errors.dates} />
      </div>

      <Field
        label="Gasten" type="number" min={1} max={maxGuests} value={guests}
        onChange={(e) => setGuests(Math.max(1, Math.min(maxGuests, Number(e.target.value) || 1)))}
        description={`Maximaal ${maxGuests} gasten.`}
      />

      {nights > 0 ? (
        <dl className="fp-booking__summary">
          <div>
            <dt>{formatPrice(pricePerNight)} × {nights} nacht{nights === 1 ? "" : "en"}</dt>
            <dd>{formatPrice(pricePerNight * nights)}</dd>
          </div>
          {extraGuests > 0 && extraPersonPrice > 0 ? (
            <div>
              <dt>Extra persoon × {extraGuests} × {nights}</dt>
              <dd>{formatPrice(extraGuests * extraPersonPrice * nights)}</dd>
            </div>
          ) : null}
          {deposit > 0 ? (
            <div>
              <dt>Borg (eenmalig)</dt>
              <dd>{formatPrice(deposit)}</dd>
            </div>
          ) : null}
          <div className="fp-booking__total">
            <dt>Totaal</dt>
            <dd>{formatPrice(total)}</dd>
          </div>
        </dl>
      ) : null}

      <div className="fp-booking__guest">
        <Field label="Naam" autoComplete="name" value={name}
          onChange={(e) => setName(e.target.value)} error={errors.name} />
        <Field label="E-mailadres" type="email" autoComplete="email" value={email}
          onChange={(e) => setEmail(e.target.value)} error={errors.email} />
        <Field label="Telefoonnummer" type="tel" autoComplete="tel" value={phone}
          onChange={(e) => setPhone(e.target.value)} error={errors.phone} />
        <Field label="Opmerkingen (optioneel)" value={notes}
          onChange={(e) => setNotes(e.target.value)} />
      </div>

      {submitFout ? <p className="fp-booking__error" role="alert">{submitFout}</p> : null}

      <Button type="submit" disabled={busy || nights < minStay} className="fp-booking__submit">
        {busy ? "Versturen…" : submitLabel}
      </Button>
      {footnote ? <div className="fp-booking__footnote">{footnote}</div> : null}
    </form>
  );
}
blocks/booking/booking.css
/**
 * Booking-blocks — stylesheet. Uitsluitend contract-tokens (tokens/CONTRACT.md).
 * Vereist primitives/ui/primitives.css + tokens/motion.css ernaast.
 */

.fp-booking {
  display: flex; flex-direction: column; gap: 1rem;
  background: var(--card);
  color: var(--card-foreground);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1.375rem;
}
.fp-booking__head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
.fp-booking__title { margin: 0; font-size: 0.9375rem; font-weight: 600; }
.fp-booking__price { margin: 0; font-size: 0.8125rem; color: var(--muted-foreground); }
.fp-booking__price strong { font-size: 1.25rem; font-weight: 600; color: var(--foreground); }

.fp-booking__dates { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
@media (max-width: 420px) { .fp-booking__dates { grid-template-columns: 1fr; } }

.fp-booking__summary {
  margin: 0; display: flex; flex-direction: column; gap: 0.5rem;
  background: var(--muted); border-radius: var(--radius);
  padding: 0.875rem 1rem; font-size: 0.8125rem;
}
.fp-booking__summary > div { display: flex; justify-content: space-between; gap: 1rem; }
.fp-booking__summary dt { color: var(--muted-foreground); }
.fp-booking__summary dd { margin: 0; font-variant-numeric: tabular-nums; }
.fp-booking__total {
  padding-top: 0.5rem; border-top: 1px solid var(--border); font-weight: 600;
}
.fp-booking__total dt { color: var(--foreground); }

.fp-booking__guest { display: flex; flex-direction: column; gap: 0.75rem; padding-top: 0.25rem; }

.fp-booking__error { margin: 0; font-size: 0.8125rem; color: var(--danger); }
.fp-booking__submit { width: 100%; }
.fp-booking__footnote { font-size: 0.75rem; color: var(--muted-foreground); text-align: center; }

.fp-booking--success { text-align: center; }
.fp-booking--success .fp-booking__title { color: var(--success); }
.fp-booking__text { margin: 0; font-size: 0.875rem; color: var(--muted-foreground); }

/* ── Sectie-receptuur: kalender + formulier ─────────────────────────────── */
.fp-booking-section {
  display: grid; grid-template-columns: 3fr 2fr; gap: 1rem; align-items: start;
}
@media (max-width: 860px) { .fp-booking-section { grid-template-columns: 1fr; } }