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 (