206 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|