feat: online booking portal (closes groombook/groombook#3) (#27)

Add customer-facing booking flow with three public API endpoints
(/api/book/services, /api/book/availability, /api/book/appointments)
and a four-step React wizard (service → date/time → contact info → confirm).

Availability is computed from real groomer schedules with slot-level
conflict detection. Booking auto-creates or matches clients by email
and uses a transaction to guard against race conditions.

Co-authored-by: Groom Book CTO <cto@groombook.app>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #27.
This commit is contained in:
groombook-paperclip[bot]
2026-03-17 20:16:12 +00:00
committed by GitHub
parent b767a00b5f
commit e524099214
4 changed files with 853 additions and 0 deletions
+4
View File
@@ -8,6 +8,7 @@ import { servicesRouter } from "./routes/services.js";
import { appointmentsRouter } from "./routes/appointments.js";
import { staffRouter } from "./routes/staff.js";
import { invoicesRouter } from "./routes/invoices.js";
import { bookRouter } from "./routes/book.js";
import { authMiddleware } from "./middleware/auth.js";
const app = new Hono();
@@ -25,6 +26,9 @@ app.use(
// Health check (no auth required)
app.get("/health", (c) => c.json({ status: "ok" }));
// Public booking routes — no auth required, must be registered before auth middleware
app.route("/api/book", bookRouter);
// Protected API routes
const api = app.basePath("/api");
api.use("*", authMiddleware);
+257
View File
@@ -0,0 +1,257 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import {
and,
eq,
gt,
gte,
lt,
ne,
getDb,
services,
staff,
appointments,
clients,
pets,
} from "@groombook/db";
export const bookRouter = new Hono();
// Business hours (UTC) — 09:0017:00
const BUSINESS_START_HOUR = 9;
const BUSINESS_END_HOUR = 17;
// ─── GET /api/book/services ─────────────────────────────────────────────────
// Public: list active services for the booking flow
bookRouter.get("/services", async (c) => {
const db = getDb();
const rows = await db
.select()
.from(services)
.where(eq(services.active, true))
.orderBy(services.name);
return c.json(rows);
});
// ─── GET /api/book/availability ─────────────────────────────────────────────
// Public: return ISO startTime strings for slots where ≥1 groomer is free
// Query params: serviceId (uuid), date (YYYY-MM-DD)
bookRouter.get("/availability", async (c) => {
const serviceId = c.req.query("serviceId");
const dateStr = c.req.query("date");
if (!serviceId || !dateStr) {
return c.json({ error: "serviceId and date are required" }, 400);
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
return c.json({ error: "date must be YYYY-MM-DD" }, 400);
}
const db = getDb();
const [service] = await db
.select()
.from(services)
.where(and(eq(services.id, serviceId), eq(services.active, true)));
if (!service) return c.json({ error: "Service not found" }, 404);
const groomers = await db
.select({ id: staff.id })
.from(staff)
.where(and(eq(staff.active, true), eq(staff.role, "groomer")));
if (groomers.length === 0) return c.json([]);
const dayStart = new Date(`${dateStr}T00:00:00Z`);
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
// Fetch all active appointments for the day (any groomer)
const booked = await db
.select({
staffId: appointments.staffId,
startTime: appointments.startTime,
endTime: appointments.endTime,
})
.from(appointments)
.where(
and(
gte(appointments.startTime, dayStart),
lt(appointments.startTime, dayEnd),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
);
const durationMs = service.durationMinutes * 60_000;
const slots: string[] = [];
let slotStart = dayStart.getTime();
while (slotStart + durationMs <= dayEnd.getTime()) {
const slotEnd = slotStart + durationMs;
const hasGroomer = groomers.some(({ id: groomerId }) =>
!booked.some(
(a) =>
a.staffId === groomerId &&
a.startTime.getTime() < slotEnd &&
a.endTime.getTime() > slotStart
)
);
if (hasGroomer) slots.push(new Date(slotStart).toISOString());
slotStart += durationMs;
}
return c.json(slots);
});
// ─── POST /api/book/appointments ─────────────────────────────────────────────
// Public: create a booking. Finds or creates client by email, always creates pet.
const bookingSchema = z.object({
serviceId: z.string().uuid(),
startTime: z.string().datetime(),
clientName: z.string().min(1).max(200),
clientEmail: z.string().email(),
clientPhone: z.string().max(50).optional(),
petName: z.string().min(1).max(200),
petSpecies: z.string().min(1).max(100),
petBreed: z.string().max(100).optional(),
notes: z.string().max(2000).optional(),
});
bookRouter.post(
"/appointments",
zValidator("json", bookingSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const start = new Date(body.startTime);
const [service] = await db
.select()
.from(services)
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
if (!service) return c.json({ error: "Service not found" }, 404);
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
// Find all active groomers
const groomers = await db
.select({ id: staff.id })
.from(staff)
.where(and(eq(staff.active, true), eq(staff.role, "groomer")));
if (groomers.length === 0) {
return c.json({ error: "No groomers available" }, 409);
}
// Find conflicting appointments for this time window
const booked = await db
.select({ staffId: appointments.staffId })
.from(appointments)
.where(
and(
lt(appointments.startTime, end),
gt(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
);
const busyIds = new Set(booked.map((a) => a.staffId));
const freeGroomer = groomers.find(({ id }) => !busyIds.has(id));
if (!freeGroomer) {
return c.json(
{ error: "No groomers available at this time. Please choose another slot." },
409
);
}
// Find or create client by email
let [client] = await db
.select()
.from(clients)
.where(eq(clients.email, body.clientEmail));
if (!client) {
const inserted = await db
.insert(clients)
.values({
name: body.clientName,
email: body.clientEmail,
phone: body.clientPhone ?? null,
})
.returning();
client = inserted[0];
}
if (!client) return c.json({ error: "Failed to create client" }, 500);
// Create pet
const petInserted = await db
.insert(pets)
.values({
clientId: client.id,
name: body.petName,
species: body.petSpecies,
breed: body.petBreed ?? null,
})
.returning();
const pet = petInserted[0];
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
// Insert appointment in a transaction to guard against race conditions
let appointment;
try {
appointment = await db.transaction(async (tx) => {
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.staffId, freeGroomer.id),
lt(appointments.startTime, end),
gt(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
)
.limit(1);
if (conflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
const apptInserted = await tx
.insert(appointments)
.values({
clientId: client.id,
petId: pet.id,
serviceId: body.serviceId,
staffId: freeGroomer.id,
startTime: start,
endTime: end,
notes: body.notes ?? null,
})
.returning();
return apptInserted[0];
});
} catch (err: unknown) {
const code = (err as Error & { statusCode?: number }).statusCode;
if (code === 409) {
return c.json(
{ error: "This slot was just taken. Please choose another time." },
409
);
}
throw err;
}
if (!appointment) return c.json({ error: "Failed to create appointment" }, 500);
return c.json({ appointment, client, pet }, 201);
}
);
+17
View File
@@ -4,6 +4,7 @@ import { ClientsPage } from "./pages/Clients.js";
import { ServicesPage } from "./pages/Services.js";
import { StaffPage } from "./pages/Staff.js";
import { InvoicesPage } from "./pages/Invoices.js";
import { BookPage } from "./pages/Book.js";
const NAV_LINKS = [
{ to: "/", label: "Appointments" },
@@ -28,6 +29,21 @@ export function App() {
}}
>
<strong style={{ marginRight: "1rem", fontSize: 16 }}>Groom Book</strong>
<Link
to="/book"
style={{
padding: "0.35rem 0.75rem",
borderRadius: 4,
textDecoration: "none",
fontSize: 14,
fontWeight: 600,
color: "#fff",
background: "#4f8a6f",
marginRight: "0.5rem",
}}
>
Book
</Link>
{NAV_LINKS.map(({ to, label }) => {
const active =
to === "/" ? location.pathname === "/" : location.pathname.startsWith(to);
@@ -57,6 +73,7 @@ export function App() {
<Route path="/services" element={<ServicesPage />} />
<Route path="/staff" element={<StaffPage />} />
<Route path="/invoices" element={<InvoicesPage />} />
<Route path="/book" element={<BookPage />} />
</Routes>
</main>
</div>
+575
View File
@@ -0,0 +1,575 @@
import { useEffect, useState } from "react";
import type { Service } from "@groombook/types";
// ─── Types ───────────────────────────────────────────────────────────────────
interface BookingBody {
serviceId: string;
startTime: string;
clientName: string;
clientEmail: string;
clientPhone: string;
petName: string;
petSpecies: string;
petBreed: string;
notes: string;
}
interface BookingResult {
appointment: { id: string; startTime: string; endTime: string };
client: { id: string; name: string; email: string | null };
pet: { id: string; name: string };
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function fmtPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
function fmtDuration(minutes: number): string {
if (minutes < 60) return `${minutes} min`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function fmtDateLong(isoDate: string): string {
const d = new Date(isoDate + "T12:00:00Z");
return d.toLocaleDateString([], { weekday: "long", year: "numeric", month: "long", day: "numeric" });
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StepIndicator({ step }: { step: number }) {
const steps = ["Service", "Date & Time", "Your Info", "Confirm"];
return (
<div style={{ display: "flex", gap: 0, marginBottom: "1.5rem" }}>
{steps.map((label, i) => {
const idx = i + 1;
const active = idx === step;
const done = idx < step;
return (
<div
key={label}
style={{
flex: 1,
textAlign: "center",
padding: "0.5rem 0.25rem",
fontSize: 12,
fontWeight: active ? 700 : 400,
color: active ? "#4f8a6f" : done ? "#4f8a6f" : "#9ca3af",
borderBottom: `3px solid ${active ? "#4f8a6f" : done ? "#4f8a6f" : "#e5e7eb"}`,
}}
>
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: "50%",
background: active ? "#4f8a6f" : done ? "#4f8a6f" : "#e5e7eb",
color: active || done ? "#fff" : "#6b7280",
fontSize: 12,
fontWeight: 700,
marginRight: 4,
}}
>
{done ? "✓" : idx}
</span>
{label}
</div>
);
})}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function BookPage() {
const [step, setStep] = useState(1);
// Step 1 — service
const [services, setServices] = useState<Service[]>([]);
const [servicesLoading, setServicesLoading] = useState(true);
const [selectedService, setSelectedService] = useState<Service | null>(null);
// Step 2 — date & time
const [date, setDate] = useState(todayIso());
const [slots, setSlots] = useState<string[]>([]);
const [slotsLoading, setSlotsLoading] = useState(false);
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
// Step 3 — contact info
const [form, setForm] = useState<BookingBody>({
serviceId: "",
startTime: "",
clientName: "",
clientEmail: "",
clientPhone: "",
petName: "",
petSpecies: "",
petBreed: "",
notes: "",
});
const [formError, setFormError] = useState<string | null>(null);
// Step 4 — result
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<BookingResult | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
// Load services on mount
useEffect(() => {
fetch("/api/book/services")
.then((r) => r.json() as Promise<Service[]>)
.then(setServices)
.catch(() => setServices([]))
.finally(() => setServicesLoading(false));
}, []);
// Load slots when service or date changes (step 2)
useEffect(() => {
if (!selectedService || !date) return;
setSlotsLoading(true);
setSelectedSlot(null);
fetch(
`/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}`
)
.then((r) => r.json() as Promise<string[]>)
.then(setSlots)
.catch(() => setSlots([]))
.finally(() => setSlotsLoading(false));
}, [selectedService, date]);
function goToStep2(svc: Service) {
setSelectedService(svc);
setForm((f) => ({ ...f, serviceId: svc.id }));
setStep(2);
}
function goToStep3() {
if (!selectedSlot) return;
setForm((f) => ({ ...f, startTime: selectedSlot }));
setStep(3);
}
function goToStep4() {
if (!form.clientName.trim() || !form.clientEmail.trim() || !form.petName.trim() || !form.petSpecies.trim()) {
setFormError("Please fill in all required fields.");
return;
}
setFormError(null);
setStep(4);
}
async function submitBooking() {
setSubmitting(true);
setSubmitError(null);
try {
const res = await fetch("/api/book/appointments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
serviceId: form.serviceId,
startTime: form.startTime,
clientName: form.clientName,
clientEmail: form.clientEmail,
clientPhone: form.clientPhone || undefined,
petName: form.petName,
petSpecies: form.petSpecies,
petBreed: form.petBreed || undefined,
notes: form.notes || undefined,
}),
});
if (!res.ok) {
const body = (await res.json()) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
const data = (await res.json()) as BookingResult;
setResult(data);
setStep(5);
} catch (e: unknown) {
setSubmitError(e instanceof Error ? e.message : "Something went wrong. Please try again.");
} finally {
setSubmitting(false);
}
}
// ── Styles ──
const card: React.CSSProperties = {
background: "#fff",
border: "1px solid #e5e7eb",
borderRadius: 8,
padding: "1rem",
cursor: "pointer",
};
const selectedCard: React.CSSProperties = {
...card,
border: "2px solid #4f8a6f",
background: "#f0faf5",
};
const input: React.CSSProperties = {
width: "100%",
padding: "0.5rem 0.75rem",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 14,
boxSizing: "border-box",
};
const label: React.CSSProperties = {
display: "block",
fontSize: 13,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
};
const btn: React.CSSProperties = {
padding: "0.6rem 1.25rem",
borderRadius: 6,
border: "none",
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
};
const primaryBtn: React.CSSProperties = {
...btn,
background: "#4f8a6f",
color: "#fff",
};
const secondaryBtn: React.CSSProperties = {
...btn,
background: "#f3f4f6",
color: "#374151",
};
return (
<div style={{ maxWidth: 640, margin: "0 auto", padding: "1rem" }}>
<div style={{ marginBottom: "1.5rem" }}>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1f2937", margin: 0 }}>
Book an Appointment
</h1>
<p style={{ fontSize: 14, color: "#6b7280", marginTop: 4 }}>
Schedule a grooming appointment for your pet in minutes.
</p>
</div>
{step < 5 && <StepIndicator step={step} />}
{/* ── Step 1: Select Service ── */}
{step === 1 && (
<div>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: "0.75rem" }}>
Choose a service
</h2>
{servicesLoading && <p style={{ color: "#6b7280" }}>Loading services</p>}
{!servicesLoading && services.length === 0 && (
<p style={{ color: "#ef4444" }}>No services available. Please contact us to book.</p>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{services.map((svc) => (
<div
key={svc.id}
style={selectedService?.id === svc.id ? selectedCard : card}
onClick={() => goToStep2(svc)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && goToStep2(svc)}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div style={{ fontWeight: 600, fontSize: 15, color: "#1f2937" }}>{svc.name}</div>
{svc.description && (
<div style={{ fontSize: 13, color: "#6b7280", marginTop: 2 }}>{svc.description}</div>
)}
</div>
<div style={{ textAlign: "right", flexShrink: 0, marginLeft: "1rem" }}>
<div style={{ fontWeight: 700, color: "#4f8a6f", fontSize: 15 }}>
{fmtPrice(svc.basePriceCents)}
</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>{fmtDuration(svc.durationMinutes)}</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* ── Step 2: Date & Time ── */}
{step === 2 && selectedService && (
<div>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 4 }}>Choose a date and time</h2>
<p style={{ fontSize: 13, color: "#6b7280", marginBottom: "1rem" }}>
{selectedService.name} {fmtDuration(selectedService.durationMinutes)} {fmtPrice(selectedService.basePriceCents)}
</p>
<div style={{ marginBottom: "1rem" }}>
<label style={label}>Date</label>
<input
type="date"
value={date}
min={todayIso()}
style={{ ...input, width: "auto" }}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<div style={{ marginBottom: "1.25rem" }}>
<label style={label}>Available times on {fmtDateLong(date)}</label>
{slotsLoading && <p style={{ color: "#6b7280", fontSize: 13 }}>Checking availability</p>}
{!slotsLoading && slots.length === 0 && (
<p style={{ color: "#6b7280", fontSize: 13 }}>
No available slots on this date. Please try another day.
</p>
)}
{!slotsLoading && slots.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginTop: "0.5rem" }}>
{slots.map((slot) => (
<button
key={slot}
onClick={() => setSelectedSlot(slot)}
style={{
padding: "0.4rem 0.85rem",
borderRadius: 6,
border: `2px solid ${selectedSlot === slot ? "#4f8a6f" : "#d1d5db"}`,
background: selectedSlot === slot ? "#4f8a6f" : "#fff",
color: selectedSlot === slot ? "#fff" : "#374151",
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
}}
>
{fmtTime(slot)}
</button>
))}
</div>
)}
</div>
<div style={{ display: "flex", gap: "0.75rem" }}>
<button style={secondaryBtn} onClick={() => setStep(1)}>Back</button>
<button
style={{ ...primaryBtn, opacity: selectedSlot ? 1 : 0.5 }}
disabled={!selectedSlot}
onClick={goToStep3}
>
Continue
</button>
</div>
</div>
)}
{/* ── Step 3: Contact Info ── */}
{step === 3 && (
<div>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: "1rem" }}>Your information</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<fieldset style={{ border: "1px solid #e5e7eb", borderRadius: 8, padding: "0.75rem 1rem" }}>
<legend style={{ fontSize: 13, fontWeight: 600, color: "#374151", padding: "0 0.25rem" }}>
Contact details
</legend>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div>
<label style={label}>Full name *</label>
<input
style={input}
value={form.clientName}
onChange={(e) => setForm((f) => ({ ...f, clientName: e.target.value }))}
placeholder="Jane Smith"
/>
</div>
<div>
<label style={label}>Email *</label>
<input
type="email"
style={input}
value={form.clientEmail}
onChange={(e) => setForm((f) => ({ ...f, clientEmail: e.target.value }))}
placeholder="jane@example.com"
/>
</div>
<div>
<label style={label}>Phone</label>
<input
type="tel"
style={input}
value={form.clientPhone}
onChange={(e) => setForm((f) => ({ ...f, clientPhone: e.target.value }))}
placeholder="(555) 000-1234"
/>
</div>
</div>
</fieldset>
<fieldset style={{ border: "1px solid #e5e7eb", borderRadius: 8, padding: "0.75rem 1rem" }}>
<legend style={{ fontSize: 13, fontWeight: 600, color: "#374151", padding: "0 0.25rem" }}>
Pet details
</legend>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div>
<label style={label}>Pet name *</label>
<input
style={input}
value={form.petName}
onChange={(e) => setForm((f) => ({ ...f, petName: e.target.value }))}
placeholder="Buddy"
/>
</div>
<div>
<label style={label}>Species *</label>
<select
style={input}
value={form.petSpecies}
onChange={(e) => setForm((f) => ({ ...f, petSpecies: e.target.value }))}
>
<option value="">Select species</option>
<option value="dog">Dog</option>
<option value="cat">Cat</option>
<option value="rabbit">Rabbit</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label style={label}>Breed</label>
<input
style={input}
value={form.petBreed}
onChange={(e) => setForm((f) => ({ ...f, petBreed: e.target.value }))}
placeholder="Golden Retriever"
/>
</div>
<div>
<label style={label}>Notes for groomer</label>
<textarea
style={{ ...input, minHeight: 64, resize: "vertical", fontFamily: "inherit" }}
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
placeholder="Any special requests or things we should know…"
/>
</div>
</div>
</fieldset>
</div>
{formError && (
<p style={{ color: "#ef4444", fontSize: 13, marginTop: "0.75rem" }}>{formError}</p>
)}
<div style={{ display: "flex", gap: "0.75rem", marginTop: "1.25rem" }}>
<button style={secondaryBtn} onClick={() => setStep(2)}>Back</button>
<button style={primaryBtn} onClick={goToStep4}>Review booking</button>
</div>
</div>
)}
{/* ── Step 4: Confirm ── */}
{step === 4 && selectedService && selectedSlot && (
<div>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: "1rem" }}>Confirm your booking</h2>
<div style={{ ...card, cursor: "default", marginBottom: "1.25rem" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem", fontSize: 14 }}>
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes)}</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
<div style={{ fontWeight: 600 }}>{fmtDateLong(date)}</div>
<div style={{ color: "#6b7280" }}>{fmtTime(selectedSlot)}</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Client</div>
<div style={{ fontWeight: 600 }}>{form.clientName}</div>
<div style={{ color: "#6b7280" }}>{form.clientEmail}</div>
{form.clientPhone && <div style={{ color: "#6b7280" }}>{form.clientPhone}</div>}
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Pet</div>
<div style={{ fontWeight: 600 }}>{form.petName}</div>
<div style={{ color: "#6b7280", textTransform: "capitalize" }}>{form.petSpecies}{form.petBreed ? ` · ${form.petBreed}` : ""}</div>
</div>
{form.notes && (
<div style={{ gridColumn: "1 / -1" }}>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Notes</div>
<div style={{ color: "#374151" }}>{form.notes}</div>
</div>
)}
</div>
</div>
{submitError && (
<p style={{ color: "#ef4444", fontSize: 13, marginBottom: "0.75rem" }}>{submitError}</p>
)}
<div style={{ display: "flex", gap: "0.75rem" }}>
<button style={secondaryBtn} onClick={() => setStep(3)} disabled={submitting}>Back</button>
<button
style={{ ...primaryBtn, opacity: submitting ? 0.7 : 1 }}
onClick={submitBooking}
disabled={submitting}
>
{submitting ? "Booking…" : "Confirm booking"}
</button>
</div>
</div>
)}
{/* ── Step 5: Success ── */}
{step === 5 && result && (
<div style={{ textAlign: "center", padding: "2rem 1rem" }}>
<div style={{ fontSize: 48, marginBottom: "0.75rem" }}>🐾</div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: "#1f2937", marginBottom: "0.5rem" }}>
Booking confirmed!
</h2>
<p style={{ color: "#6b7280", fontSize: 14, marginBottom: "1.5rem" }}>
We&apos;ve booked {result.pet.name} in for{" "}
{selectedService?.name} on {fmtDateLong(date)} at{" "}
{fmtTime(result.appointment.startTime)}.
</p>
<div style={{ ...card, cursor: "default", textAlign: "left", marginBottom: "1.5rem" }}>
<p style={{ margin: 0, fontSize: 14, color: "#374151" }}>
A confirmation will be sent to <strong>{result.client.email}</strong>.
If you need to reschedule or cancel, please contact us.
</p>
</div>
<button
style={primaryBtn}
onClick={() => {
setStep(1);
setSelectedService(null);
setSelectedSlot(null);
setResult(null);
setForm({
serviceId: "", startTime: "", clientName: "", clientEmail: "",
clientPhone: "", petName: "", petSpecies: "", petBreed: "", notes: "",
});
}}
>
Book another appointment
</button>
</div>
)}
</div>
);
}