All files / src/utils trackAppointmentEvent.ts

92.5% Statements 37/40
92.59% Branches 25/27
100% Functions 9/9
92.5% Lines 37/40

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174                    2x   2x     2x   2x         18x 18x 18x                                                                                         2x                                         2x       6x 6x 3x   3x       1x       8x       20x 20x 20x 12x   8x 8x 20x                   20x 20x 3x   17x             17x             17x 17x 17x 9x     8x                           8x 4x   4x 2x   2x    
/**
 * Privacy-safe booking analytics. Emits English, PII-free events that cross
 * Shadow DOM via document + composed CustomEvent. If etracker is present on
 * the host page, also forwards object, category, and action. The Events
 * report lists the object, so a reserved slot is new_calendar, new_list,
 * rebooking_calendar, or rebooking_list, and a confirmation is
 * new_confirmed or rebooking_confirmed. One visit in ten is kept, and that
 * visit emits every event in the chain. Never throws.
 */
 
export const APPOINTMENT_TRACK_EVENT = "zms-appointment-track";
 
export const APPOINTMENT_TRACK_CATEGORY = "appointment";
 
/** One visit in this many is tracked for its whole chain. */
export const APPOINTMENT_TRACK_SAMPLE_INTERVAL = 10;
 
const APPOINTMENT_TRACK_SAMPLE_KEY = "zms-appointment-track-sample";
 
let memorySample: boolean | undefined;
 
export function resetAppointmentTrackSample(): void {
  memorySample = undefined;
  try {
    sessionStorage.removeItem(APPOINTMENT_TRACK_SAMPLE_KEY);
  } catch {
    // Storage can be blocked. The in-memory decision is already cleared.
  }
}
 
export type AppointmentTrackObject =
  | "appointment"
  | "appointment_detail"
  | "appointment_slider"
  | "appointment_overview"
  | "service_finder"
  | "service_combination"
  | "timestamp_selection"
  | "contact"
  | "summary"
  | "reserved"
  | "updated"
  | "preconfirmed"
  | "confirmed"
  | "cancelled"
  | "rebooking"
  | "login";
 
export type AppointmentTrackAction =
  "view" | "click" | "success" | "started" | "abandoned";
 
export type AppointmentTrackFlow = "new" | "rebooking";
 
export type AppointmentTrackSlotUi = "list" | "calendar";
 
export type AppointmentTrackWidget =
  | "appointment"
  | "appointment_detail"
  | "appointment_slider"
  | "appointment_overview";
 
export type AppointmentTrackPayload = {
  object: AppointmentTrackObject;
  action: AppointmentTrackAction;
  flow?: AppointmentTrackFlow;
  slot_ui?: AppointmentTrackSlotUi;
  widget?: AppointmentTrackWidget;
};
 
const BOOKING_SCREENS: Record<number, AppointmentTrackObject> = {
  1: "timestamp_selection",
  2: "contact",
  3: "summary",
};
 
declare global {
  interface Window {
    _etracker?: {
      sendEvent?: (event: unknown) => void;
    };
    et_UserDefinedEvent?: new (
      objectName: string,
      category: string,
      action?: string,
      type?: string
    ) => unknown;
  }
}
 
export function bookingFlow(isRebooking: boolean): AppointmentTrackFlow {
  return isRebooking ? "rebooking" : "new";
}
 
export function trackAppointmentScreenFromView(view: number): void {
  const object = BOOKING_SCREENS[view];
  if (!object) {
    return;
  }
  trackAppointmentEvent({ object, action: "view" });
}
 
export function trackWidgetView(widget: AppointmentTrackWidget): void {
  trackAppointmentEvent({ object: widget, action: "view" });
}
 
function rollVisitSample(): boolean {
  return Math.floor(Math.random() * APPOINTMENT_TRACK_SAMPLE_INTERVAL) === 0;
}
 
function visitIsTracked(): boolean {
  try {
    const stored = sessionStorage.getItem(APPOINTMENT_TRACK_SAMPLE_KEY);
    if (stored === "1" || stored === "0") {
      return stored === "1";
    }
    const keep = rollVisitSample();
    sessionStorage.setItem(APPOINTMENT_TRACK_SAMPLE_KEY, keep ? "1" : "0");
    return keep;
  } catch {
    if (memorySample === undefined) {
      memorySample = rollVisitSample();
    }
    return memorySample;
  }
}
 
export function trackAppointmentEvent(payload: AppointmentTrackPayload): void {
  try {
    if (!visitIsTracked()) {
      return;
    }
    document.dispatchEvent(
      new CustomEvent(APPOINTMENT_TRACK_EVENT, {
        detail: payload,
        bubbles: true,
        composed: true,
      })
    );
    sendToEtracker(payload);
  } catch {
    // Tracking must never break the booking flow.
  }
}
 
function sendToEtracker(payload: AppointmentTrackPayload): void {
  const sendEvent = window._etracker?.sendEvent;
  const UserDefinedEvent = window.et_UserDefinedEvent;
  if (!sendEvent || !UserDefinedEvent) {
    return;
  }
 
  sendEvent(
    new UserDefinedEvent(
      etrackerObject(payload),
      APPOINTMENT_TRACK_CATEGORY,
      payload.action
    )
  );
}
 
/**
 * The etracker Events report shows this string in the object column.
 * Reserved slots and confirmations are named so flow and view are visible there.
 */
function etrackerObject(payload: AppointmentTrackPayload): string {
  if (payload.object === "reserved" && payload.flow && payload.slot_ui) {
    return `${payload.flow}_${payload.slot_ui}`;
  }
  if (payload.object === "confirmed" && payload.flow) {
    return `${payload.flow}_confirmed`;
  }
  return payload.object;
}