/** * Conversion-click capture for DCS customer sites. * * WHY THIS EXISTS * --------------- * The money-moment on most DCS sites happens on somebody else's domain: KEPT books * physical therapy on StrideThera and gym slots on Momence, Just Posh books on Vagaro, * and every site has a `tel:` number. We do not own those transactions — but we can own * the *number*, and "your site sent N people to booking last month" is the single most * renewal-relevant sentence we can put in front of a paying owner. * * Hand-wiring a `@click` handler onto every booking button does not survive contact with * a redesign. Evidence, from the first monthly owner reports (2026-07): * * - KEPT's `BookPTAppointmentClicked` event fired for the last time on 2026-06-03. The * header "Book Online" link in the `isClassicExperience` branch never got a handler, * so the site's single most prominent booking CTA has been invisible ever since — * silently, with nothing failing. * - Six other KEPT event names went dark in the same window. * - Just Posh, which uses *delegated* capture (one document-level listener, classified * by href), recorded 95 booking clicks across 11 pages in the same 30 days without a * single per-button code change. * * This module is the delegated approach, generalised for the fleet: one capture-phase * click listener, an allow-list of booking hosts, and a classification of every outbound * link into `booking` / `phone` / `email` / `social` / `external` / `internal` / `button`. * * WHY IT WAS NOT ENOUGH (C-326, 2026-07-26) * ----------------------------------------- * Everything above shipped, was exported from the package index, and was consumed by * EXACTLY ZERO fleet sites. Two independent same-day reviews proved the cost: Bryan's * Handyman ships five `tel:` CTAs — the site's only conversion action — and fires nothing; * Kim Duff Homes' App Insights holds 6 pageViews in 365 days. A capability that every site * must opt into is a capability every site forgets. * * So the opt-in is gone. `../conversionAutoInstall` installs this tracker as a side effect * of importing `@duffcloudservices/cms` — which every fleet site already does — and finds * its own sink (GA4's `window.gtag`, or a telemetry transport that calls the published * `window.__dcsConversionAttach`). Nothing in a site repo changes. * * THREE PROPERTIES THAT MUST SURVIVE ANY EDIT HERE * ----------------------------------------------- * 1. NO PII IN THE PAYLOAD. `tel:` / `sms:` / `mailto:` targets are redacted to a scheme * plus a short digest ({@link redactHref}) — never the raw number or address. * 2. NO DOUBLE COUNTING. Exactly one delegated listener may be live per document, and a * click on a form's submit control is deliberately NOT counted (the `submit` event is). * This is the C-288/C-291 discipline: 52% of one site's page views were duplicates * because two trackers counted the same navigation, so the shared layer refuses rather * than risks it. * 3. IT NEVER THROWS INTO A USER'S CLICK. * * INTERLOCK — MUST SURVIVE APP INSIGHTS SDK DEFERRAL (fleet P3) * ------------------------------------------------------------ * A planned fleet change defers (or lazily loads, or drops) the App Insights browser SDK * to buy page-speed budget. Conversion capture MUST NOT be a casualty of that: an owner * report that silently reports zero bookings is worse than no report. * * Therefore this module: * 1. never imports the App Insights SDK, and has no dependency on it; * 2. starts listening immediately, before any telemetry transport exists; * 3. buffers captured events in a bounded FIFO while no sink is attached; * 4. drains that buffer the moment a sink attaches — including a sink attached * minutes later by a lazy loader via {@link attachConversionSink}. * * A deferred SDK therefore costs at most a delayed flush, never a lost conversion. If you * change this file, keep that property and keep the test that asserts it * (`useConversionTracking.test.ts` → "survives a deferred sink"). */ import { getCurrentInstance, onBeforeUnmount, onMounted } from 'vue' /** How a clicked element was classified. */ export type ConversionInteractionType = | 'booking' | 'phone' | 'email' | 'form_submit' | 'social' | 'external' | 'internal' | 'button' /** * The interaction types that ARE the money moment — the ones an owner report counts. * * Everything else (`social`, `external`, `internal`, `button`) is navigation telemetry and * is still captured, but it must never be summed into "conversions". Keeping the set here, * rather than in each report's query, means one edit changes every consumer. */ export const CONVERSION_INTERACTION_TYPES: readonly ConversionInteractionType[] = [ 'booking', 'phone', 'email', 'form_submit', ] /** Whether an interaction type counts as a conversion. */ export function isConversionType(type: ConversionInteractionType): boolean { return CONVERSION_INTERACTION_TYPES.includes(type) } /** A captured conversion event, in App Insights `trackEvent` shape. */ export interface ConversionEvent { /** Event name — `site_interaction` by default (see {@link ConversionTrackingOptions.eventName}). */ name: string /** Flat string properties; App Insights `customDimensions`. */ properties: { interaction_type: ConversionInteractionType /** * `'true'` when {@link isConversionType} holds. A string, not a boolean, because App * Insights `customDimensions` and GA4 event params are both string maps — so the * owner-report query is one predicate (`is_conversion == "true"`) instead of an * interaction-type IN-list that every new report has to remember to keep in sync. */ is_conversion: string /** Visible label / aria-label of the clicked element, truncated. */ label: string /** * Destination, query string and fragment stripped, and REDACTED for contact schemes: * a `tel:` / `sms:` / `mailto:` href becomes `tel:#` — never the raw number or * address. See {@link redactHref}. Empty for buttons and form submits. */ href: string /** URL scheme of the destination including the colon (`tel:`, `https:`), or `''`. */ href_scheme: string /** Host of the destination, or `''` for buttons, form submits and contact schemes. */ href_host: string /** * Short digest of the contact target (the phone number / email address), or `''` for * everything else. Lets a report say "CTA A got 12 taps, CTA B got 3" without ever * storing the contact string itself. */ href_hash: string /** Path of the page the click happened on. */ page_path: string /** Host of the page the click happened on. */ host: string /** Schema version, so a report can tell old rows from new ones. */ capture_version: string } } /** A telemetry transport. Typically `(e) => telemetry.trackEvent(e)`. */ export type ConversionSink = (event: ConversionEvent) => void export interface ConversionTrackingOptions { /** * Where to send events. Optional on purpose — omit it when the telemetry SDK is * deferred, and call {@link attachConversionSink} once it has loaded. Events captured * in the meantime are buffered, not dropped. */ sink?: ConversionSink /** * Fire-and-forget transports that receive every event AS WELL AS `sink`, and that do NOT * count as "a sink exists" for buffering purposes. * * This distinction is load-bearing. GA4's `gtag` is a mirror: the deploy injects it as a * synchronous inline snippet, so it is either there when the click happens or the hit is * genuinely unavailable — there is nothing to wait for. App Insights is a `sink`: it boots * on an idle callback minutes later, which is exactly what the buffer exists to survive. * Treating GA4 as a `sink` would have satisfied the "do we have somewhere to send this?" * test on every site and quietly disabled the deferred-SDK interlock. */ mirrors?: ConversionSink[] /** * Custom event name. Defaults to `site_interaction` — the name Just Posh has been * emitting since 2026-04, so its history stays one continuous series. Pass * `'dcs_conversion'` on a site with no existing history if you prefer the canonical name. */ eventName?: string /** * Extra hostnames to treat as booking destinations, on top of {@link DEFAULT_BOOKING_HOSTS}. * Matched on host suffix, so `vagaro.com` also matches `www.vagaro.com`. */ bookingHosts?: string[] /** * Same-origin paths that mean "booking" (e.g. a self-hosted `/book`). Matched as a * prefix on the pathname. */ bookingPaths?: string[] /** Extra social hostnames on top of {@link DEFAULT_SOCIAL_HOSTS}. */ socialHosts?: string[] /** Max events held while no sink is attached. Default 50 — bounded so a bot cannot grow it. */ bufferLimit?: number /** Drop untrusted (script-dispatched) clicks. Default `false`. */ requireTrusted?: boolean /** Document to bind to. Defaults to the ambient `document`. Injected in tests. */ target?: Document /** * Also capture managed-form submissions as `form_submit`. Default `true`. * * A form submit is a conversion on every DCS site that has a form, and it is the one * affordance a click listener alone cannot see honestly: clicking "Send" on a form that * then fails validation is not a lead. So the `submit` event — not the click — is * authoritative, and clicks on submit controls are deliberately dropped to keep the two * from counting the same action twice. */ captureFormSubmits?: boolean /** * Honour the visitor's Do Not Track signal. Default `true`. * * With DNT on, `start()` binds nothing at all — no listener, no buffer, no event. This * is a measurement rail, not a consent platform: if a site ever grows a real consent * banner, gate {@link installConversionCapture} on it rather than weakening this. */ respectDoNotTrack?: boolean /** * Bind a second delegated listener even though one is already live on this document. * * Off by default and it should stay off. Two delegated listeners on one document count * every click twice — the same defect class C-288 measured as 52% duplicate page views * on a live customer site, which the portal then reported to the owner as traffic. If * you are reaching for this, you almost certainly want `stop()` on the existing tracker. */ force?: boolean } /** * Schema version stamped on every captured event. Bump on a breaking property change. * * `2` (C-326): `href` is redacted for `tel:`/`sms:`/`mailto:`, and `is_conversion`, * `href_scheme` + `href_hash` were added. Version `1` rows carry raw contact hrefs and no * conversion flag, so a report spanning the boundary must branch on this. */ export const CONVERSION_CAPTURE_VERSION = '2' /** * Booking/scheduling vendors seen across the DCS fleet plus the common SMB schedulers. * Host-suffix matched. Add per-site extras via `bookingHosts` rather than editing this. */ export const DEFAULT_BOOKING_HOSTS: readonly string[] = [ 'stridethera.com', // KEPT — physical therapy 'momence.com', // KEPT — gym / recovery 'vagaro.com', // Just Posh 'acuityscheduling.com', 'booksy.com', 'calendly.com', 'fresha.com', 'janeapp.com', 'mindbodyonline.com', 'schedulicity.com', 'setmore.com', 'simplepractice.com', 'square.site', 'squareup.com', ] /** Social destinations. Host-suffix matched. */ export const DEFAULT_SOCIAL_HOSTS: readonly string[] = [ 'facebook.com', 'instagram.com', 'linkedin.com', 'pinterest.com', 'threads.net', 'tiktok.com', 'x.com', 'youtube.com', ] const LABEL_MAX_LENGTH = 120 const DEFAULT_BUFFER_LIMIT = 50 /** Identical events inside this window are collapsed (guards double-fired handlers). */ const DEDUPE_WINDOW_MS = 400 function hostMatches(host: string, patterns: readonly string[]): boolean { const h = host.toLowerCase() return patterns.some((p) => { const needle = p.toLowerCase() return h === needle || h.endsWith(`.${needle}`) }) } /** Strip query + fragment. A booking URL can carry a name or email in its query string. */ function sanitizeUrl(raw: string): string { const trimmed = raw.trim() if (!trimmed) return '' const cut = trimmed.split('#')[0].split('?')[0] return cut.slice(0, 300) } /** Schemes whose target is a contact string and must never be emitted verbatim. */ const CONTACT_SCHEMES = ['tel:', 'sms:', 'mailto:'] as const /** * FNV-1a (32-bit), base36. Synchronous on purpose: this runs inside a click handler, where * `crypto.subtle` — the only real hash a browser offers — is async and would force the * event to be built after the navigation has already started. * * BE HONEST ABOUT WHAT THIS IS. It is not anonymisation. A site publishes two or three * phone numbers, so anybody holding the site could brute-force the digest back in * milliseconds. What it buys is real but narrow: the contact string never lands in an * analytics store (GA4 forbids PII in event params outright), a support screenshot of the * events table cannot leak a customer's mailbox, and the value is still stable enough to * answer "which CTA did they tap". Do not describe it as anything more than that. */ export function hashTarget(value: string): string { let h = 0x811c9dc5 for (let i = 0; i < value.length; i += 1) { h ^= value.charCodeAt(i) h = Math.imul(h, 0x01000193) >>> 0 } return h.toString(36) } /** The scheme of an href, including the colon (`tel:`, `https:`), or `''`. */ export function hrefScheme(href: string, pageHost: string): string { const raw = href.trim() if (!raw) return '' const lower = raw.toLowerCase() const contact = CONTACT_SCHEMES.find((s) => lower.startsWith(s)) if (contact) return contact if (lower.startsWith('#')) return '' try { return new URL(raw, `https://${pageHost || 'localhost'}/`).protocol } catch { return '' } } /** * Split an href into the parts that are safe to emit. * * For `tel:` / `sms:` / `mailto:` the target is a person's or business's contact string, so * it is replaced by `#` and the digest is also surfaced on its own. For * everything else the href passes through {@link sanitizeUrl} unchanged — a booking URL's * path is the useful part and its query (which CAN carry a name or email) is already gone. */ export function redactHref( href: string, pageHost: string, ): { href: string; scheme: string; hash: string } { const raw = href.trim() if (!raw) return { href: '', scheme: '', hash: '' } const lower = raw.toLowerCase() const contact = CONTACT_SCHEMES.find((s) => lower.startsWith(s)) if (contact) { // Normalise before hashing so `tel:+1 (248) 385-2926` and `tel:+12483852926` — the // same CTA written two ways in one codebase — do not split into two rows. const target = raw.slice(contact.length).split('?')[0].trim().toLowerCase() const normalized = contact === 'mailto:' ? target : target.replace(/[^0-9+]/gu, '') const hash = normalized ? hashTarget(normalized) : '' return { href: `${contact}#${hash}`, scheme: contact, hash } } return { href: sanitizeUrl(raw), scheme: hrefScheme(raw, pageHost), hash: '' } } /** * Whether this element is the control that submits a form. * * Clicks on these are dropped so the `submit` event can be the single source of truth — * see {@link ConversionTrackingOptions.captureFormSubmits}. Note the HTML default: a * `