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