diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md
index 0cb6db1..16f797d 100644
--- a/UAT_PLAYBOOK.md
+++ b/UAT_PLAYBOOK.md
@@ -316,6 +316,26 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-WEB-5.23.2 | Save pet — error state | Trigger an API save failure (e.g. network error) | Error message displayed; edit form stays open; no data cleared |
| TC-WEB-5.23.3 | Save pet — saving indicator | Click Save | Spinner/indicator shown while request is in flight; form controls disabled |
+
+### 5.24 Booking Funnel Analytics Events (GRO-1794)
+
+
+| # | Scenario | Steps | Expected |
+|---|----------|-------|----------|
+| TC-WEB-5.24.1 | booking_step_service — public | Select a service in the public booking wizard | `booking_step_service` CustomEvent fires with detail.step="service" and detail.flow="public" |
+| TC-WEB-5.24.2 | booking_step_time — public | Select a time slot and click Continue | `booking_step_time` fires with detail.step="time" and detail.flow="public" |
+| TC-WEB-5.24.3 | booking_step_contact — public | Fill in contact/pet form, click "Review booking" | `booking_step_contact` fires with detail.step="contact" and detail.flow="public" |
+| TC-WEB-5.24.4 | booking_step_submit — public | Confirm and submit the booking | `booking_step_submit` fires with detail.step="submit" and detail.flow="public" |
+| TC-WEB-5.24.5 | booking_confirmed — public | Navigate to /booking-confirmed | `booking_confirmed` fires once on mount with detail.step="confirmed" and detail.flow="public" |
+| TC-WEB-5.24.6 | booking_error — public | Navigate to /booking-error | `booking_error` fires once on mount with detail.step="error" and detail.flow="public" |
+| TC-WEB-5.24.7 | booking_step_service — portal | Select a pet in the portal BookingFlow | `booking_step_service` fires with detail.step="service" and detail.flow="portal" |
+| TC-WEB-5.24.8 | booking_step_time — portal | Pick a date and time in portal BookingFlow | `booking_step_time` fires with detail.step="time" and detail.flow="portal" |
+| TC-WEB-5.24.9 | booking_step_contact — portal | Proceed from groomer selection to review screen | `booking_step_contact` fires with detail.step="groomer" and detail.flow="portal" |
+| TC-WEB-5.24.10 | booking_step_submit — portal | Submit booking in portal BookingFlow | `booking_step_submit` fires with detail.step="submit" and detail.flow="portal" |
+| TC-WEB-5.24.11 | booking_confirmed — portal | Portal booking request succeeds | Inline success state is shown and `booking_confirmed` fires with detail.step="confirmed" and detail.flow="portal" |
+| TC-WEB-5.24.12 | No PII in analytics payloads | Fire each event and inspect detail object | Payload contains only: step, flow, timestamp — no names, emails, phone numbers, or pet names |
+| TC-WEB-5.24.13 | No-op safe | Trigger analytics with window.dispatchEvent blocked (e.g. CSP) | No error thrown; booking flow completes normally |
+
## 6. Pass/Fail Criteria
**Pass:**
diff --git a/src/__tests__/BookingCancelled.test.tsx b/src/__tests__/BookingCancelled.test.tsx
new file mode 100644
index 0000000..2d6ada1
--- /dev/null
+++ b/src/__tests__/BookingCancelled.test.tsx
@@ -0,0 +1,27 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { BookingCancelledPage } from "../pages/BookingCancelled.tsx";
+
+describe("BookingCancelledPage", () => {
+ it("renders the cancelled heading", () => {
+ render();
+ expect(screen.getByRole("heading", { name: /Appointment Cancelled/i })).toBeInTheDocument();
+ });
+
+ it("renders the cancelled body text", () => {
+ render();
+ expect(screen.getByText(/Your appointment has been cancelled/i)).toBeInTheDocument();
+ });
+
+ it("has a Book again link pointing to /admin/book", () => {
+ render();
+ const link = screen.getByRole("link", { name: /Book again/i });
+ expect(link).toHaveAttribute("href", "/admin/book");
+ });
+
+ it("has a Back to Portal link pointing to /", () => {
+ render();
+ const link = screen.getByRole("link", { name: /Back to Portal/i });
+ expect(link).toHaveAttribute("href", "/");
+ });
+});
diff --git a/src/__tests__/BookingError.test.tsx b/src/__tests__/BookingError.test.tsx
new file mode 100644
index 0000000..24344aa
--- /dev/null
+++ b/src/__tests__/BookingError.test.tsx
@@ -0,0 +1,38 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { BookingErrorPage } from "../pages/BookingError.tsx";
+import { BUSINESS_CONTACT_INFO } from "../lib/contact.ts";
+
+describe("BookingErrorPage", () => {
+ it("renders the error heading", () => {
+ render();
+ expect(screen.getByRole("heading", { name: /Link Invalid or Expired/i })).toBeInTheDocument();
+ });
+
+ it("renders the error body text", () => {
+ render();
+ expect(screen.getByText(/This confirmation link is invalid/i)).toBeInTheDocument();
+ });
+
+ it("has a Start a new booking link pointing to /admin/book", () => {
+ render();
+ const link = screen.getByRole("link", { name: /Start a new booking/i });
+ expect(link).toHaveAttribute("href", "/admin/book");
+ });
+
+ it("has a Back to Portal link pointing to /", () => {
+ render();
+ const link = screen.getByRole("link", { name: /Back to Portal/i });
+ expect(link).toHaveAttribute("href", "/");
+ });
+
+ it("displays business contact phone", () => {
+ render();
+ expect(screen.getByText(new RegExp(BUSINESS_CONTACT_INFO.phone.replace(/[()]/g, "\\$&")))).toBeInTheDocument();
+ });
+
+ it("displays business contact email", () => {
+ render();
+ expect(screen.getByText(new RegExp(BUSINESS_CONTACT_INFO.email))).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/analytics.test.ts b/src/__tests__/analytics.test.ts
new file mode 100644
index 0000000..f24dd11
--- /dev/null
+++ b/src/__tests__/analytics.test.ts
@@ -0,0 +1,83 @@
+import { describe, it, expect, vi } from "vitest";
+import { ANALYTICS_EVENTS, fireAnalyticsEvent } from "../lib/analytics";
+
+describe("analytics", () => {
+ describe("ANALYTICS_EVENTS constants", () => {
+ it("exports all required event names", () => {
+ expect(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE).toBe("booking_step_service");
+ expect(ANALYTICS_EVENTS.BOOKING_STEP_TIME).toBe("booking_step_time");
+ expect(ANALYTICS_EVENTS.BOOKING_STEP_CONTACT).toBe("booking_step_contact");
+ expect(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT).toBe("booking_step_submit");
+ expect(ANALYTICS_EVENTS.BOOKING_CONFIRMED).toBe("booking_confirmed");
+ expect(ANALYTICS_EVENTS.BOOKING_ERROR).toBe("booking_error");
+ });
+
+ it("has no duplicate event names", () => {
+ const values = Object.values(ANALYTICS_EVENTS);
+ const unique = new Set(values);
+ expect(unique.size).toBe(values.length);
+ });
+ });
+
+ describe("fireAnalyticsEvent", () => {
+ it("dispatches a CustomEvent with the correct event name", () => {
+ const listener = vi.fn();
+ window.addEventListener(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, listener);
+ fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, { step: "service", flow: "public" });
+ expect(listener).toHaveBeenCalledTimes(1);
+ const event = listener.mock.calls[0]![0] as CustomEvent;
+ expect(event.type).toBe("booking_step_service");
+ expect(event.detail.step).toBe("service");
+ expect(event.detail.flow).toBe("public");
+ expect(event.detail.timestamp).toBeDefined();
+ window.removeEventListener(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, listener);
+ });
+
+ it("includes a timestamp in the event detail", () => {
+ const listener = vi.fn();
+ window.addEventListener(ANALYTICS_EVENTS.BOOKING_CONFIRMED, listener);
+ fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_CONFIRMED, { step: "confirmed", flow: "public" });
+ const event = listener.mock.calls[0]![0] as CustomEvent;
+ expect(event.detail.timestamp).toBeTruthy();
+ expect(new Date(event.detail.timestamp as string)).toBeInstanceOf(Date);
+ window.removeEventListener(ANALYTICS_EVENTS.BOOKING_CONFIRMED, listener);
+ });
+
+ it("does not throw when called with no payload", () => {
+ expect(() => {
+ fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_ERROR, {});
+ }).not.toThrow();
+ });
+
+ it("does not throw when window.dispatchEvent throws", () => {
+ const original = window.dispatchEvent;
+ window.dispatchEvent = () => {
+ throw new Error("analytics blocked");
+ };
+ expect(() => {
+ fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "public" });
+ }).not.toThrow();
+ window.dispatchEvent = original;
+ });
+
+ it("fires events for all event types", () => {
+ const events = Object.values(ANALYTICS_EVENTS);
+ for (const eventName of events) {
+ const listener = vi.fn();
+ window.addEventListener(eventName, listener);
+ fireAnalyticsEvent(eventName as typeof events[number], { step: "test", flow: "public" });
+ expect(listener).toHaveBeenCalledTimes(1);
+ window.removeEventListener(eventName, listener);
+ }
+ });
+
+ it("does not include PII in payload", () => {
+ // Payload only contains step, flow, and timestamp — no names, emails, or phones
+ const payload = { step: "contact", flow: "public" };
+ const keys = Object.keys(payload);
+ const piish = ["name", "email", "phone", "clientName", "clientEmail", "clientPhone", "petName"];
+ const hasPII = piish.some((k) => keys.includes(k));
+ expect(hasPII).toBe(false);
+ });
+ });
+});
diff --git a/src/index.css b/src/index.css
index 61c98ed..32b3b5e 100644
--- a/src/index.css
+++ b/src/index.css
@@ -8,6 +8,19 @@
--color-accent-dark: color-mix(in srgb, var(--color-accent) 78%, #000);
--color-accent-light: color-mix(in srgb, var(--color-accent) 18%, #fff);
--color-accent-lighter: color-mix(in srgb, var(--color-accent) 9%, #fff);
+
+ /* Semantic / booking page tokens */
+ --color-error: #dc2626;
+ --color-error-dark: #b91c1c;
+ --color-error-bg: #fef2f2;
+ --color-cancelled: #ea580c;
+ --color-cancelled-dark: #c2410c;
+ --color-cancelled-bg: #fff7ed;
+ --color-success: #16a34a;
+ --color-success-dark: #15803d;
+ --color-success-bg: #f0fdf4;
+ --color-text-secondary: #4b5563;
+ --color-surface: #fff;
}
*, *::before, *::after {
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
new file mode 100644
index 0000000..ef3f22d
--- /dev/null
+++ b/src/lib/analytics.ts
@@ -0,0 +1,40 @@
+// Analytics event names — single source of truth
+export const ANALYTICS_EVENTS = {
+ BOOKING_STEP_SERVICE: "booking_step_service",
+ BOOKING_STEP_TIME: "booking_step_time",
+ BOOKING_STEP_CONTACT: "booking_step_contact",
+ BOOKING_STEP_SUBMIT: "booking_step_submit",
+ BOOKING_CONFIRMED: "booking_confirmed",
+ BOOKING_ERROR: "booking_error",
+} as const;
+
+export type AnalyticsEventName = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS];
+
+export type AnalyticsPayload = {
+ step?: string;
+ flow?: "public" | "portal";
+ [key: string]: string | undefined;
+};
+
+/**
+ * Fires a lightweight analytics event via window.dispatchEvent.
+ * No-op safe: failures are swallowed so analytics never breaks the booking flow.
+ * Designed for later Plausible/GTM integration.
+ */
+export function fireAnalyticsEvent(
+ eventName: AnalyticsEventName,
+ payload: AnalyticsPayload = {}
+): void {
+ try {
+ window.dispatchEvent(
+ new CustomEvent(eventName, {
+ detail: {
+ ...payload,
+ timestamp: new Date().toISOString(),
+ },
+ })
+ );
+ } catch {
+ // no-op: analytics must never break the booking flow
+ }
+}
diff --git a/src/lib/contact.ts b/src/lib/contact.ts
new file mode 100644
index 0000000..d2908cf
--- /dev/null
+++ b/src/lib/contact.ts
@@ -0,0 +1,7 @@
+// Business contact information — update values to reflect actual business details.
+// Used on error/cancellation pages to help customers reach the business.
+export const BUSINESS_CONTACT_INFO = {
+ phone: "(555) 000-1234",
+ email: "hello@groombook.example.com",
+ address: "123 Main St, Anytown, USA",
+} as const;
diff --git a/src/pages/Book.tsx b/src/pages/Book.tsx
index 179b0f0..a855377 100644
--- a/src/pages/Book.tsx
+++ b/src/pages/Book.tsx
@@ -1,6 +1,7 @@
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 ───────────────────────────────────────────────────────────────────
@@ -193,12 +194,14 @@ export function BookPage() {
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() {
@@ -208,6 +211,7 @@ export function BookPage() {
}
setFormError(null);
setStep(4);
+ fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_CONTACT, { step: "contact", flow: "public" });
}
async function submitBooking() {
@@ -236,6 +240,7 @@ export function BookPage() {
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) {
diff --git a/src/pages/BookingCancelled.tsx b/src/pages/BookingCancelled.tsx
index 9b2ab4a..6ded7af 100644
--- a/src/pages/BookingCancelled.tsx
+++ b/src/pages/BookingCancelled.tsx
@@ -1,3 +1,10 @@
+const STRINGS = {
+ heading: "Appointment Cancelled",
+ body: "Your appointment has been cancelled. If this was a mistake or you'd like to rebook, please contact us.",
+ bookAgain: "Book again",
+ backToPortal: "Back to Portal",
+} as const;
+
export function BookingCancelledPage() {
return (
✗
-
- Appointment Cancelled
+
+ {STRINGS.heading}
-
- Your appointment has been cancelled. If this was a mistake or you'd
- like to rebook, please contact us.
+
+ {STRINGS.body}
-
- Back to Portal
-
+
+
);
diff --git a/src/pages/BookingConfirmed.tsx b/src/pages/BookingConfirmed.tsx
index a56ba96..ed306fc 100644
--- a/src/pages/BookingConfirmed.tsx
+++ b/src/pages/BookingConfirmed.tsx
@@ -1,4 +1,11 @@
+import { useEffect } from "react";
+import { ANALYTICS_EVENTS, fireAnalyticsEvent } from "../lib/analytics";
+
export function BookingConfirmedPage() {
+ useEffect(() => {
+ fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_CONFIRMED, { step: "confirmed", flow: "public" });
+ }, []);
+
return (
{
+ fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_ERROR, { step: "error", flow: "public" });
+ }, []);
+
return (
⚠️
-
- Link Invalid or Expired
+
+ {STRINGS.heading}
-
- This confirmation link is invalid, has already been used, or your
- appointment has already passed. Please contact us if you need help.
+
+ {STRINGS.body}
-
- Back to Portal
-
+
+
+
+
+
{STRINGS.contactLabel}
+
+ {BUSINESS_CONTACT_INFO.phone} · {BUSINESS_CONTACT_INFO.email}
+
+
);
diff --git a/src/portal/sections/Appointments.tsx b/src/portal/sections/Appointments.tsx
index 13038c5..04ab1aa 100644
--- a/src/portal/sections/Appointments.tsx
+++ b/src/portal/sections/Appointments.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
+import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics';
export interface Appointment {
id: string;
@@ -736,6 +737,11 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
const [notes, setNotes] = useState('');
const [recurring, setRecurring] = useState('');
const [confirmed, setConfirmed] = useState(false);
+ useEffect(() => {
+ if (confirmed) {
+ fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_CONFIRMED, { step: "confirmed", flow: "portal" });
+ }
+ }, [confirmed]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState
(null);
const [submitting, setSubmitting] = useState(false);
@@ -827,6 +833,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
if (response.ok) {
setConfirmed(true);
+ fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "portal" });
setTimeout(() => {
window.location.reload();
}, 1500);
@@ -902,6 +909,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
onClick={() => {
setSelectedPet(pet);
setStep(2);
+ fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, { step: "service", flow: "portal" });
}}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-colors ${
selectedPet?.id === pet.id
@@ -1060,7 +1068,10 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
Back