booking-inquiry
FC-0001bookingblockv1.0.0Boekingsaanvraag-formulier: periode, gasten, prijsopbouw (nachten + toeslag + borg) en gastgegevens; verstuurt via het onSubmit-contract 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; } }