From 344a32e3e421f4bcf8b92239693ec357321c100c Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Tue, 26 May 2026 12:00:55 +0000 Subject: [PATCH 1/5] feat(GRO-1792): add recovery paths to booking error and cancellation pages - Add "Start a new booking" button to BookingError linking to /admin/book - Add "Book again" button to BookingCancelled linking to /admin/book - Add business contact info section to BookingError (from BUSINESS_CONTACT_INFO constant) - Replace hardcoded colors with CSS variables (--color-error, --color-cancelled, etc.) - Add page-level string constants to eliminate hardcoded strings - Add unit tests for both pages (9 tests passing) Co-Authored-By: Paperclip --- src/__tests__/BookingCancelled.test.tsx | 27 +++++++++ src/__tests__/BookingError.test.tsx | 38 ++++++++++++ src/index.css | 13 +++++ src/lib/contact.ts | 7 +++ src/pages/BookingCancelled.tsx | 68 ++++++++++++++------- src/pages/BookingError.tsx | 78 ++++++++++++++++++------- 6 files changed, 187 insertions(+), 44 deletions(-) create mode 100644 src/__tests__/BookingCancelled.test.tsx create mode 100644 src/__tests__/BookingError.test.tsx create mode 100644 src/lib/contact.ts 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..0f30f6e --- /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(BUSINESS_CONTACT_INFO.phone)).toBeInTheDocument(); + }); + + it("displays business contact email", () => { + render(); + expect(screen.getByText(BUSINESS_CONTACT_INFO.email)).toBeInTheDocument(); + }); +}); 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/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/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/BookingError.tsx b/src/pages/BookingError.tsx index 62639d9..ba5d43d 100644 --- a/src/pages/BookingError.tsx +++ b/src/pages/BookingError.tsx @@ -1,3 +1,13 @@ +import { BUSINESS_CONTACT_INFO } from "../lib/contact"; + +const STRINGS = { + heading: "Link Invalid or Expired", + body: "This confirmation link is invalid, has already been used, or your appointment has already passed. Please contact us if you need help.", + newBooking: "Start a new booking", + backToPortal: "Back to Portal", + contactLabel: "Need help?", +} as const; + export function BookingErrorPage() { 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} +

+
); From 2e99ed520f8c86067990f0ffef4db148ae3ca1d9 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 26 May 2026 12:38:58 +0000 Subject: [PATCH 2/5] feat(GRO-1794): add booking funnel analytics events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/__tests__/analytics.test.ts | 83 ++++++++++++++++++++++++++++ src/lib/analytics.ts | 40 ++++++++++++++ src/pages/Book.tsx | 5 ++ src/pages/BookingConfirmed.tsx | 7 +++ src/pages/BookingError.tsx | 6 ++ src/portal/sections/Appointments.tsx | 18 +++++- 6 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/analytics.test.ts create mode 100644 src/lib/analytics.ts diff --git a/src/__tests__/analytics.test.ts b/src/__tests__/analytics.test.ts new file mode 100644 index 0000000..a8c5288 --- /dev/null +++ b/src/__tests__/analytics.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } 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/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/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/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 (
{ + 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); @@ -801,6 +807,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); @@ -876,6 +883,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 @@ -1034,7 +1042,10 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) { Back