Files
web/src/pages/Book.tsx
T
Flea Flicker 2e99ed520f
CI / Lint & Typecheck (pull_request) Failing after 15s
CI / Test (pull_request) Failing after 18s
CI / Build & Push Docker Image (pull_request) Has been skipped
feat(GRO-1794): add booking funnel analytics events
- New analytics utility (src/lib/analytics.ts) with ANALYTICS_EVENTS constants
  and fireAnalyticsEvent() – thin wrapper over window.dispatchEvent, no-op safe
  Built for Plausible/GTM integration later.

- Public booking wizard (Book.tsx): fires step-transition events at each step
  (service → time → contact → submit) plus booking_confirmed on the dedicated
  confirmation page.

- Portal BookingFlow (Appointments.tsx): fires equivalent events for the
  portal booking flow. booking_confirmed fires via useEffect when the inline
  success state is shown.

- BookingErrorPage: fires booking_error on mount (no PII in payload).

Events include step name and flow type (public/portal) but contain no PII:
no names, emails, phone numbers, or pet names in any payload.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 12:38:58 +00:00

668 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { Service } from "@groombook/types";
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from "../lib/analytics";
// ─── Types ───────────────────────────────────────────────────────────────────
interface BookingBody {
serviceId: string;
startTime: string;
clientName: string;
clientEmail: string;
clientPhone: string;
petName: string;
petSpecies: string;
petBreed: string;
petSizeCategory: string;
petCoatType: 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 ? "var(--color-primary)" : done ? "var(--color-primary)" : "#9ca3af",
borderBottom: `3px solid ${active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#e5e7eb"}`,
}}
>
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: "50%",
background: active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#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 [dateError, setDateError] = useState<string | null>(null);
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: "",
petSizeCategory: "",
petCoatType: "",
notes: "",
});
const [formError, setFormError] = useState<string | null>(null);
// Pre-fill form from URL params (e.g., ?clientName=Jane&clientEmail=jane@example.com)
const [searchParams] = useSearchParams();
useEffect(() => {
const clientName = searchParams.get("clientName");
const clientEmail = searchParams.get("clientEmail");
const clientPhone = searchParams.get("clientPhone");
const petName = searchParams.get("petName");
const petSpecies = searchParams.get("petSpecies");
const petBreed = searchParams.get("petBreed");
const petSizeCategory = searchParams.get("petSizeCategory");
const petCoatType = searchParams.get("petCoatType");
if (clientName || clientEmail || clientPhone || petName || petSpecies || petBreed || petSizeCategory || petCoatType) {
setForm((f) => ({
...f,
...(clientName && { clientName }),
...(clientEmail && { clientEmail }),
...(clientPhone && { clientPhone }),
...(petName && { petName }),
...(petSpecies && { petSpecies }),
...(petBreed && { petBreed }),
...(petSizeCategory && { petSizeCategory }),
...(petCoatType && { petCoatType }),
}));
}
}, [searchParams]);
// 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);
const params = new URLSearchParams({
serviceId: selectedService.id,
date,
});
if (form.petSizeCategory) params.set("petSizeCategory", form.petSizeCategory);
if (form.petCoatType) params.set("petCoatType", form.petCoatType);
fetch(`/api/book/availability?${params.toString()}`)
.then((r) => r.json() as Promise<string[]>)
.then(setSlots)
.catch(() => setSlots([]))
.finally(() => setSlotsLoading(false));
}, [selectedService, date, form.petSizeCategory, form.petCoatType]);
function goToStep2(svc: Service) {
setSelectedService(svc);
setForm((f) => ({ ...f, serviceId: svc.id }));
setStep(2);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, { step: "service", flow: "public" });
}
function goToStep3() {
if (!selectedSlot) return;
setForm((f) => ({ ...f, startTime: selectedSlot }));
setStep(3);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_TIME, { step: "time", flow: "public" });
}
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);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_CONTACT, { step: "contact", flow: "public" });
}
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,
petSizeCategory: form.petSizeCategory || undefined,
petCoatType: form.petCoatType || 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;
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "public" });
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 var(--color-primary)",
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: "var(--color-primary)",
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: "var(--color-primary)", 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) => {
const val = e.target.value;
// HTML5 date input enforces yyyy-MM-dd; empty value means invalid format
if (!val) {
setDateError("Please enter a valid date (YYYY-MM-DD).");
setDate("");
} else {
setDateError(null);
setDate(val);
}
}}
/>
{dateError && (
<p style={{ color: "#dc2626", fontSize: 12, marginTop: 4 }}>{dateError}</p>
)}
</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 ? "var(--color-primary)" : "#d1d5db"}`,
background: selectedSlot === slot ? "var(--color-primary)" : "#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}>Pet size</label>
<select
style={input}
value={form.petSizeCategory}
onChange={(e) => setForm((f) => ({ ...f, petSizeCategory: e.target.value }))}
>
<option value="">Select size</option>
<option value="small">Small (under 15 lbs)</option>
<option value="medium">Medium (1540 lbs)</option>
<option value="large">Large (4080 lbs)</option>
<option value="xlarge">X-Large (over 80 lbs)</option>
</select>
</div>
<div>
<label style={label}>Coat type</label>
<select
style={input}
value={form.petCoatType}
onChange={(e) => setForm((f) => ({ ...f, petCoatType: e.target.value }))}
>
<option value="">Select coat</option>
<option value="smooth">Smooth</option>
<option value="double">Double</option>
<option value="curly">Curly</option>
<option value="wire">Wire</option>
<option value="long">Long</option>
<option value="hairless">Hairless</option>
</select>
</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)} appointment</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>
{(form.petSizeCategory || form.petCoatType) && (
<div style={{ color: "#6b7280", fontSize: 12, marginTop: 2 }}>
{form.petSizeCategory ? `${form.petSizeCategory} · ` : ""}{form.petCoatType ? form.petCoatType : ""}
</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: "",
petSizeCategory: "", petCoatType: "", notes: "",
});
}}
>
Book another appointment
</button>
</div>
)}
</div>
);
}