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>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { BUSINESS_CONTACT_INFO } from "../lib/contact";
|
||||
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from "../lib/analytics";
|
||||
|
||||
const STRINGS = {
|
||||
heading: "Link Invalid or Expired",
|
||||
@@ -9,6 +11,10 @@ const STRINGS = {
|
||||
} as const;
|
||||
|
||||
export function BookingErrorPage() {
|
||||
useEffect(() => {
|
||||
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_ERROR, { step: "error", flow: "public" });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -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;
|
||||
@@ -720,6 +721,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<string | null>(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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep(4)}
|
||||
onClick={() => {
|
||||
setStep(4);
|
||||
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_CONTACT, { step: "groomer", flow: "portal" });
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
Next
|
||||
@@ -1093,7 +1104,10 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep(5)}
|
||||
onClick={() => {
|
||||
setStep(5);
|
||||
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_TIME, { step: "time", flow: "portal" });
|
||||
}}
|
||||
disabled={!selectedDate || !selectedTime}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user