templates2.Booking.Next/components/booking/BookingFlow.tsx
w4c-templates 6a0c2e88d2
Some checks are pending
CI / quality (push) Waiting to run
chore(template): publish Booking.Next
2026-10-11 23:27:02 +03:00

206 lines
6.9 KiB
TypeScript

'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Field } from '@/components/ui';
import { formatTimeInTimeZone } from '@/lib/slots';
import { formatCurrency } from '@/lib/utils';
export type BookingService = {
id: string;
name: string;
slug: string;
durationMin: number;
priceCents: number;
staff: { id: string; name: string }[];
};
type Slot = { start: string; end: string; staffId: string; staffName: string };
export function BookingFlow({
services,
initialServiceId,
timeZone,
today,
defaultName = '',
defaultEmail = '',
}: {
services: BookingService[];
initialServiceId?: string;
timeZone: string;
today: string;
defaultName?: string;
defaultEmail?: string;
}) {
const router = useRouter();
const [serviceId, setServiceId] = useState(initialServiceId ?? services[0]?.id ?? '');
const [staffId, setStaffId] = useState('');
const [date, setDate] = useState(today);
const [slots, setSlots] = useState<Slot[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [slotStart, setSlotStart] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const service = services.find((s) => s.id === serviceId);
const loadSlots = useCallback(async () => {
if (!serviceId || !date) return;
setLoadingSlots(true);
setError(null);
setSlotStart('');
try {
const params = new URLSearchParams({ serviceId, date });
if (staffId) params.set('staffId', staffId);
const res = await fetch(`/api/availability?${params.toString()}`);
const data = (await res.json().catch(() => ({}))) as { slots?: Slot[] };
setSlots(Array.isArray(data.slots) ? data.slots : []);
} catch {
setError('Could not load available times.');
setSlots([]);
} finally {
setLoadingSlots(false);
}
}, [serviceId, staffId, date]);
useEffect(() => {
void loadSlots();
}, [loadSlots]);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!slotStart) {
setError('Please choose a time slot.');
return;
}
setBusy(true);
setError(null);
const form = new FormData(e.currentTarget);
try {
const res = await fetch('/api/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serviceId,
staffId: staffId || null,
startsAt: slotStart,
customerName: String(form.get('customerName') ?? ''),
customerEmail: String(form.get('customerEmail') ?? ''),
notes: String(form.get('notes') ?? ''),
}),
});
const data = (await res.json().catch(() => ({}))) as { booking?: { id: string }; error?: string };
if (!res.ok || !data.booking) throw new Error(data.error || 'Could not create the booking.');
router.push(`/book/confirmed/${data.booking.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not create the booking.');
setBusy(false);
}
}
return (
<form className="space-y-8" onSubmit={onSubmit} noValidate>
<section className="space-y-3">
<h2 className="text-lg font-semibold">1. Choose a service</h2>
<div className="grid gap-3 sm:grid-cols-2">
{services.map((s) => (
<button
type="button"
key={s.id}
onClick={() => {
setServiceId(s.id);
setStaffId('');
}}
aria-pressed={s.id === serviceId}
className={`card text-left transition ${s.id === serviceId ? 'ring-2 ring-brand-500' : 'hover:shadow-md'}`}
>
<p className="font-semibold">{s.name}</p>
<p className="muted mt-1 text-sm">
{s.durationMin} min · {formatCurrency(s.priceCents / 100)}
</p>
</button>
))}
</div>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold">2. Choose a team member</h2>
<Field label="Team member" htmlFor="staff">
<select id="staff" className="input" value={staffId} onChange={(e) => setStaffId(e.target.value)}>
<option value="">Any available team member</option>
{service?.staff.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</Field>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold">3. Pick a date and time</h2>
<div className="max-w-xs">
<Field label="Date" htmlFor="date">
<input
id="date"
type="date"
className="input"
min={today}
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</Field>
</div>
{loadingSlots ? (
<p className="muted text-sm">Loading times…</p>
) : slots.length === 0 ? (
<p className="muted text-sm">No times available on this date. Try another day.</p>
) : (
<div className="flex flex-wrap gap-2" role="group" aria-label="Available times">
{slots.map((s) => (
<button
type="button"
key={s.start}
onClick={() => setSlotStart(s.start)}
aria-pressed={slotStart === s.start}
className={`btn-secondary ${slotStart === s.start ? 'ring-2 ring-brand-500' : ''}`}
>
{formatTimeInTimeZone(new Date(s.start), timeZone)}
</button>
))}
</div>
)}
<p className="muted text-xs">Times shown in {timeZone}.</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold">4. Your details</h2>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Name" htmlFor="customerName">
<input id="customerName" name="customerName" className="input" defaultValue={defaultName} required autoComplete="name" />
</Field>
<Field label="Email" htmlFor="customerEmail">
<input id="customerEmail" name="customerEmail" type="email" className="input" defaultValue={defaultEmail} required autoComplete="email" />
</Field>
<div className="sm:col-span-2">
<Field label="Notes (optional)" htmlFor="notes">
<textarea id="notes" name="notes" rows={3} className="input" />
</Field>
</div>
</div>
</section>
{error && (
<p role="alert" className="text-sm text-red-600">
{error}
</p>
)}
<button type="submit" className="btn-primary" disabled={busy || !slotStart}>
{busy ? 'Booking…' : 'Confirm booking'}
</button>
</form>
);
}