@duffcloudservices/cms 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +244 -8
  2. package/dist/chunk-A5F4C72F.js +500 -0
  3. package/dist/chunk-A5F4C72F.js.map +1 -0
  4. package/dist/{chunk-F3EIWEZD.js → chunk-HVSF23P7.js} +971 -73
  5. package/dist/chunk-HVSF23P7.js.map +1 -0
  6. package/dist/editor/editorBridge.d.ts +13 -1
  7. package/dist/editor/editorBridge.js +75 -5
  8. package/dist/editor/editorBridge.js.map +1 -1
  9. package/dist/headHonesty-OzxvLuwd.d.ts +222 -0
  10. package/dist/index.d.ts +365 -22
  11. package/dist/index.js +421 -21
  12. package/dist/index.js.map +1 -1
  13. package/dist/installSeoHead-kWQwObez.d.ts +627 -0
  14. package/dist/plugins/index.d.ts +90 -6
  15. package/dist/plugins/index.js +530 -49
  16. package/dist/plugins/index.js.map +1 -1
  17. package/dist/seo/index.d.ts +763 -4
  18. package/dist/seo/index.js +2 -2
  19. package/dist/{vitepressTransform-DfmABXmK.d.ts → vitepressTransform-JG_zlaux.d.ts} +99 -6
  20. package/package.json +17 -6
  21. package/src/components/DcsCallButton.test.ts +58 -0
  22. package/src/components/DcsCallButton.vue +19 -4
  23. package/src/components/LiteMediaEmbed.vue +3 -3
  24. package/src/components/ManagedImage.test.ts +34 -0
  25. package/src/components/ManagedImage.vue +5 -0
  26. package/src/components/PreviewRibbon.vue +4 -1
  27. package/src/composables/useConversionTracking.test.ts +492 -0
  28. package/src/composables/useConversionTracking.ts +770 -0
  29. package/src/composables/useReleaseNotes.ts +7 -1
  30. package/src/composables/useSEO.applyHead.test.ts +150 -0
  31. package/src/composables/useSEO.ts +63 -17
  32. package/src/composables/useSiteVersion.ts +4 -1
  33. package/src/composables/useSiteVisitorSession.test.ts +56 -0
  34. package/src/composables/useSiteVisitorSession.ts +39 -3
  35. package/src/composables/useTextContent.ts +9 -1
  36. package/dist/chunk-DAYLLSEE.js +0 -3
  37. package/dist/chunk-DAYLLSEE.js.map +0 -1
  38. package/dist/chunk-F3EIWEZD.js.map +0 -1
  39. package/dist/spliceHeadHtml-CsBEucGy.d.ts +0 -254
@@ -0,0 +1,770 @@
1
+ /**
2
+ * Conversion-click capture for DCS customer sites.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * The money-moment on most DCS sites happens on somebody else's domain: KEPT books
7
+ * physical therapy on StrideThera and gym slots on Momence, Just Posh books on Vagaro,
8
+ * and every site has a `tel:` number. We do not own those transactions — but we can own
9
+ * the *number*, and "your site sent N people to booking last month" is the single most
10
+ * renewal-relevant sentence we can put in front of a paying owner.
11
+ *
12
+ * Hand-wiring a `@click` handler onto every booking button does not survive contact with
13
+ * a redesign. Evidence, from the first monthly owner reports (2026-07):
14
+ *
15
+ * - KEPT's `BookPTAppointmentClicked` event fired for the last time on 2026-06-03. The
16
+ * header "Book Online" link in the `isClassicExperience` branch never got a handler,
17
+ * so the site's single most prominent booking CTA has been invisible ever since —
18
+ * silently, with nothing failing.
19
+ * - Six other KEPT event names went dark in the same window.
20
+ * - Just Posh, which uses *delegated* capture (one document-level listener, classified
21
+ * by href), recorded 95 booking clicks across 11 pages in the same 30 days without a
22
+ * single per-button code change.
23
+ *
24
+ * This module is the delegated approach, generalised for the fleet: one capture-phase
25
+ * click listener, an allow-list of booking hosts, and a classification of every outbound
26
+ * link into `booking` / `phone` / `email` / `social` / `external` / `internal` / `button`.
27
+ *
28
+ * WHY IT WAS NOT ENOUGH (C-326, 2026-07-26)
29
+ * -----------------------------------------
30
+ * Everything above shipped, was exported from the package index, and was consumed by
31
+ * EXACTLY ZERO fleet sites. Two independent same-day reviews proved the cost: Bryan's
32
+ * Handyman ships five `tel:` CTAs — the site's only conversion action — and fires nothing;
33
+ * Kim Duff Homes' App Insights holds 6 pageViews in 365 days. A capability that every site
34
+ * must opt into is a capability every site forgets.
35
+ *
36
+ * So the opt-in is gone. `../conversionAutoInstall` installs this tracker as a side effect
37
+ * of importing `@duffcloudservices/cms` — which every fleet site already does — and finds
38
+ * its own sink (GA4's `window.gtag`, or a telemetry transport that calls the published
39
+ * `window.__dcsConversionAttach`). Nothing in a site repo changes.
40
+ *
41
+ * THREE PROPERTIES THAT MUST SURVIVE ANY EDIT HERE
42
+ * -----------------------------------------------
43
+ * 1. NO PII IN THE PAYLOAD. `tel:` / `sms:` / `mailto:` targets are redacted to a scheme
44
+ * plus a short digest ({@link redactHref}) — never the raw number or address.
45
+ * 2. NO DOUBLE COUNTING. Exactly one delegated listener may be live per document, and a
46
+ * click on a form's submit control is deliberately NOT counted (the `submit` event is).
47
+ * This is the C-288/C-291 discipline: 52% of one site's page views were duplicates
48
+ * because two trackers counted the same navigation, so the shared layer refuses rather
49
+ * than risks it.
50
+ * 3. IT NEVER THROWS INTO A USER'S CLICK.
51
+ *
52
+ * INTERLOCK — MUST SURVIVE APP INSIGHTS SDK DEFERRAL (fleet P3)
53
+ * ------------------------------------------------------------
54
+ * A planned fleet change defers (or lazily loads, or drops) the App Insights browser SDK
55
+ * to buy page-speed budget. Conversion capture MUST NOT be a casualty of that: an owner
56
+ * report that silently reports zero bookings is worse than no report.
57
+ *
58
+ * Therefore this module:
59
+ * 1. never imports the App Insights SDK, and has no dependency on it;
60
+ * 2. starts listening immediately, before any telemetry transport exists;
61
+ * 3. buffers captured events in a bounded FIFO while no sink is attached;
62
+ * 4. drains that buffer the moment a sink attaches — including a sink attached
63
+ * minutes later by a lazy loader via {@link attachConversionSink}.
64
+ *
65
+ * A deferred SDK therefore costs at most a delayed flush, never a lost conversion. If you
66
+ * change this file, keep that property and keep the test that asserts it
67
+ * (`useConversionTracking.test.ts` → "survives a deferred sink").
68
+ */
69
+ import { getCurrentInstance, onBeforeUnmount, onMounted } from 'vue'
70
+
71
+ /** How a clicked element was classified. */
72
+ export type ConversionInteractionType =
73
+ | 'booking'
74
+ | 'phone'
75
+ | 'email'
76
+ | 'form_submit'
77
+ | 'social'
78
+ | 'external'
79
+ | 'internal'
80
+ | 'button'
81
+
82
+ /**
83
+ * The interaction types that ARE the money moment — the ones an owner report counts.
84
+ *
85
+ * Everything else (`social`, `external`, `internal`, `button`) is navigation telemetry and
86
+ * is still captured, but it must never be summed into "conversions". Keeping the set here,
87
+ * rather than in each report's query, means one edit changes every consumer.
88
+ */
89
+ export const CONVERSION_INTERACTION_TYPES: readonly ConversionInteractionType[] = [
90
+ 'booking',
91
+ 'phone',
92
+ 'email',
93
+ 'form_submit',
94
+ ]
95
+
96
+ /** Whether an interaction type counts as a conversion. */
97
+ export function isConversionType(type: ConversionInteractionType): boolean {
98
+ return CONVERSION_INTERACTION_TYPES.includes(type)
99
+ }
100
+
101
+ /** A captured conversion event, in App Insights `trackEvent` shape. */
102
+ export interface ConversionEvent {
103
+ /** Event name — `site_interaction` by default (see {@link ConversionTrackingOptions.eventName}). */
104
+ name: string
105
+ /** Flat string properties; App Insights `customDimensions`. */
106
+ properties: {
107
+ interaction_type: ConversionInteractionType
108
+ /**
109
+ * `'true'` when {@link isConversionType} holds. A string, not a boolean, because App
110
+ * Insights `customDimensions` and GA4 event params are both string maps — so the
111
+ * owner-report query is one predicate (`is_conversion == "true"`) instead of an
112
+ * interaction-type IN-list that every new report has to remember to keep in sync.
113
+ */
114
+ is_conversion: string
115
+ /** Visible label / aria-label of the clicked element, truncated. */
116
+ label: string
117
+ /**
118
+ * Destination, query string and fragment stripped, and REDACTED for contact schemes:
119
+ * a `tel:` / `sms:` / `mailto:` href becomes `tel:#<digest>` — never the raw number or
120
+ * address. See {@link redactHref}. Empty for buttons and form submits.
121
+ */
122
+ href: string
123
+ /** URL scheme of the destination including the colon (`tel:`, `https:`), or `''`. */
124
+ href_scheme: string
125
+ /** Host of the destination, or `''` for buttons, form submits and contact schemes. */
126
+ href_host: string
127
+ /**
128
+ * Short digest of the contact target (the phone number / email address), or `''` for
129
+ * everything else. Lets a report say "CTA A got 12 taps, CTA B got 3" without ever
130
+ * storing the contact string itself.
131
+ */
132
+ href_hash: string
133
+ /** Path of the page the click happened on. */
134
+ page_path: string
135
+ /** Host of the page the click happened on. */
136
+ host: string
137
+ /** Schema version, so a report can tell old rows from new ones. */
138
+ capture_version: string
139
+ }
140
+ }
141
+
142
+ /** A telemetry transport. Typically `(e) => telemetry.trackEvent(e)`. */
143
+ export type ConversionSink = (event: ConversionEvent) => void
144
+
145
+ export interface ConversionTrackingOptions {
146
+ /**
147
+ * Where to send events. Optional on purpose — omit it when the telemetry SDK is
148
+ * deferred, and call {@link attachConversionSink} once it has loaded. Events captured
149
+ * in the meantime are buffered, not dropped.
150
+ */
151
+ sink?: ConversionSink
152
+ /**
153
+ * Fire-and-forget transports that receive every event AS WELL AS `sink`, and that do NOT
154
+ * count as "a sink exists" for buffering purposes.
155
+ *
156
+ * This distinction is load-bearing. GA4's `gtag` is a mirror: the deploy injects it as a
157
+ * synchronous inline snippet, so it is either there when the click happens or the hit is
158
+ * genuinely unavailable — there is nothing to wait for. App Insights is a `sink`: it boots
159
+ * on an idle callback minutes later, which is exactly what the buffer exists to survive.
160
+ * Treating GA4 as a `sink` would have satisfied the "do we have somewhere to send this?"
161
+ * test on every site and quietly disabled the deferred-SDK interlock.
162
+ */
163
+ mirrors?: ConversionSink[]
164
+ /**
165
+ * Custom event name. Defaults to `site_interaction` — the name Just Posh has been
166
+ * emitting since 2026-04, so its history stays one continuous series. Pass
167
+ * `'dcs_conversion'` on a site with no existing history if you prefer the canonical name.
168
+ */
169
+ eventName?: string
170
+ /**
171
+ * Extra hostnames to treat as booking destinations, on top of {@link DEFAULT_BOOKING_HOSTS}.
172
+ * Matched on host suffix, so `vagaro.com` also matches `www.vagaro.com`.
173
+ */
174
+ bookingHosts?: string[]
175
+ /**
176
+ * Same-origin paths that mean "booking" (e.g. a self-hosted `/book`). Matched as a
177
+ * prefix on the pathname.
178
+ */
179
+ bookingPaths?: string[]
180
+ /** Extra social hostnames on top of {@link DEFAULT_SOCIAL_HOSTS}. */
181
+ socialHosts?: string[]
182
+ /** Max events held while no sink is attached. Default 50 — bounded so a bot cannot grow it. */
183
+ bufferLimit?: number
184
+ /** Drop untrusted (script-dispatched) clicks. Default `false`. */
185
+ requireTrusted?: boolean
186
+ /** Document to bind to. Defaults to the ambient `document`. Injected in tests. */
187
+ target?: Document
188
+ /**
189
+ * Also capture managed-form submissions as `form_submit`. Default `true`.
190
+ *
191
+ * A form submit is a conversion on every DCS site that has a form, and it is the one
192
+ * affordance a click listener alone cannot see honestly: clicking "Send" on a form that
193
+ * then fails validation is not a lead. So the `submit` event — not the click — is
194
+ * authoritative, and clicks on submit controls are deliberately dropped to keep the two
195
+ * from counting the same action twice.
196
+ */
197
+ captureFormSubmits?: boolean
198
+ /**
199
+ * Honour the visitor's Do Not Track signal. Default `true`.
200
+ *
201
+ * With DNT on, `start()` binds nothing at all — no listener, no buffer, no event. This
202
+ * is a measurement rail, not a consent platform: if a site ever grows a real consent
203
+ * banner, gate {@link installConversionCapture} on it rather than weakening this.
204
+ */
205
+ respectDoNotTrack?: boolean
206
+ /**
207
+ * Bind a second delegated listener even though one is already live on this document.
208
+ *
209
+ * Off by default and it should stay off. Two delegated listeners on one document count
210
+ * every click twice — the same defect class C-288 measured as 52% duplicate page views
211
+ * on a live customer site, which the portal then reported to the owner as traffic. If
212
+ * you are reaching for this, you almost certainly want `stop()` on the existing tracker.
213
+ */
214
+ force?: boolean
215
+ }
216
+
217
+ /**
218
+ * Schema version stamped on every captured event. Bump on a breaking property change.
219
+ *
220
+ * `2` (C-326): `href` is redacted for `tel:`/`sms:`/`mailto:`, and `is_conversion`,
221
+ * `href_scheme` + `href_hash` were added. Version `1` rows carry raw contact hrefs and no
222
+ * conversion flag, so a report spanning the boundary must branch on this.
223
+ */
224
+ export const CONVERSION_CAPTURE_VERSION = '2'
225
+
226
+ /**
227
+ * Booking/scheduling vendors seen across the DCS fleet plus the common SMB schedulers.
228
+ * Host-suffix matched. Add per-site extras via `bookingHosts` rather than editing this.
229
+ */
230
+ export const DEFAULT_BOOKING_HOSTS: readonly string[] = [
231
+ 'stridethera.com', // KEPT — physical therapy
232
+ 'momence.com', // KEPT — gym / recovery
233
+ 'vagaro.com', // Just Posh
234
+ 'acuityscheduling.com',
235
+ 'booksy.com',
236
+ 'calendly.com',
237
+ 'fresha.com',
238
+ 'janeapp.com',
239
+ 'mindbodyonline.com',
240
+ 'schedulicity.com',
241
+ 'setmore.com',
242
+ 'simplepractice.com',
243
+ 'square.site',
244
+ 'squareup.com',
245
+ ]
246
+
247
+ /** Social destinations. Host-suffix matched. */
248
+ export const DEFAULT_SOCIAL_HOSTS: readonly string[] = [
249
+ 'facebook.com',
250
+ 'instagram.com',
251
+ 'linkedin.com',
252
+ 'pinterest.com',
253
+ 'threads.net',
254
+ 'tiktok.com',
255
+ 'x.com',
256
+ 'youtube.com',
257
+ ]
258
+
259
+ const LABEL_MAX_LENGTH = 120
260
+ const DEFAULT_BUFFER_LIMIT = 50
261
+ /** Identical events inside this window are collapsed (guards double-fired handlers). */
262
+ const DEDUPE_WINDOW_MS = 400
263
+
264
+ function hostMatches(host: string, patterns: readonly string[]): boolean {
265
+ const h = host.toLowerCase()
266
+ return patterns.some((p) => {
267
+ const needle = p.toLowerCase()
268
+ return h === needle || h.endsWith(`.${needle}`)
269
+ })
270
+ }
271
+
272
+ /** Strip query + fragment. A booking URL can carry a name or email in its query string. */
273
+ function sanitizeUrl(raw: string): string {
274
+ const trimmed = raw.trim()
275
+ if (!trimmed) return ''
276
+ const cut = trimmed.split('#')[0].split('?')[0]
277
+ return cut.slice(0, 300)
278
+ }
279
+
280
+ /** Schemes whose target is a contact string and must never be emitted verbatim. */
281
+ const CONTACT_SCHEMES = ['tel:', 'sms:', 'mailto:'] as const
282
+
283
+ /**
284
+ * FNV-1a (32-bit), base36. Synchronous on purpose: this runs inside a click handler, where
285
+ * `crypto.subtle` — the only real hash a browser offers — is async and would force the
286
+ * event to be built after the navigation has already started.
287
+ *
288
+ * BE HONEST ABOUT WHAT THIS IS. It is not anonymisation. A site publishes two or three
289
+ * phone numbers, so anybody holding the site could brute-force the digest back in
290
+ * milliseconds. What it buys is real but narrow: the contact string never lands in an
291
+ * analytics store (GA4 forbids PII in event params outright), a support screenshot of the
292
+ * events table cannot leak a customer's mailbox, and the value is still stable enough to
293
+ * answer "which CTA did they tap". Do not describe it as anything more than that.
294
+ */
295
+ export function hashTarget(value: string): string {
296
+ let h = 0x811c9dc5
297
+ for (let i = 0; i < value.length; i += 1) {
298
+ h ^= value.charCodeAt(i)
299
+ h = Math.imul(h, 0x01000193) >>> 0
300
+ }
301
+ return h.toString(36)
302
+ }
303
+
304
+ /** The scheme of an href, including the colon (`tel:`, `https:`), or `''`. */
305
+ export function hrefScheme(href: string, pageHost: string): string {
306
+ const raw = href.trim()
307
+ if (!raw) return ''
308
+ const lower = raw.toLowerCase()
309
+ const contact = CONTACT_SCHEMES.find((s) => lower.startsWith(s))
310
+ if (contact) return contact
311
+ if (lower.startsWith('#')) return ''
312
+ try {
313
+ return new URL(raw, `https://${pageHost || 'localhost'}/`).protocol
314
+ } catch {
315
+ return ''
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Split an href into the parts that are safe to emit.
321
+ *
322
+ * For `tel:` / `sms:` / `mailto:` the target is a person's or business's contact string, so
323
+ * it is replaced by `<scheme>#<digest>` and the digest is also surfaced on its own. For
324
+ * everything else the href passes through {@link sanitizeUrl} unchanged — a booking URL's
325
+ * path is the useful part and its query (which CAN carry a name or email) is already gone.
326
+ */
327
+ export function redactHref(
328
+ href: string,
329
+ pageHost: string,
330
+ ): { href: string; scheme: string; hash: string } {
331
+ const raw = href.trim()
332
+ if (!raw) return { href: '', scheme: '', hash: '' }
333
+
334
+ const lower = raw.toLowerCase()
335
+ const contact = CONTACT_SCHEMES.find((s) => lower.startsWith(s))
336
+ if (contact) {
337
+ // Normalise before hashing so `tel:+1 (248) 385-2926` and `tel:+12483852926` — the
338
+ // same CTA written two ways in one codebase — do not split into two rows.
339
+ const target = raw.slice(contact.length).split('?')[0].trim().toLowerCase()
340
+ const normalized =
341
+ contact === 'mailto:' ? target : target.replace(/[^0-9+]/gu, '')
342
+ const hash = normalized ? hashTarget(normalized) : ''
343
+ return { href: `${contact}#${hash}`, scheme: contact, hash }
344
+ }
345
+
346
+ return { href: sanitizeUrl(raw), scheme: hrefScheme(raw, pageHost), hash: '' }
347
+ }
348
+
349
+ /**
350
+ * Whether this element is the control that submits a form.
351
+ *
352
+ * Clicks on these are dropped so the `submit` event can be the single source of truth —
353
+ * see {@link ConversionTrackingOptions.captureFormSubmits}. Note the HTML default: a
354
+ * `<button>` inside a form with no `type` IS a submit button, which is exactly how every
355
+ * `<DcsForm>` renders its action.
356
+ */
357
+ export function isSubmitControl(el: Element): boolean {
358
+ const tag = el.tagName.toLowerCase()
359
+ if (tag !== 'button' && tag !== 'input') return false
360
+ const type = (el.getAttribute('type') ?? '').toLowerCase()
361
+ if (type === 'submit') return true
362
+ if (type) return false
363
+ return tag === 'button' && !!el.closest('form')
364
+ }
365
+
366
+ /** Read the visitor's Do Not Track signal across the three places browsers have put it. */
367
+ export function doNotTrackEnabled(): boolean {
368
+ if (typeof navigator === 'undefined' && typeof window === 'undefined') return false
369
+ const nav =
370
+ typeof navigator !== 'undefined'
371
+ ? (navigator as Navigator & { msDoNotTrack?: string })
372
+ : undefined
373
+ const win =
374
+ typeof window !== 'undefined' ? (window as Window & { doNotTrack?: string }) : undefined
375
+ const signal = nav?.doNotTrack ?? win?.doNotTrack ?? nav?.msDoNotTrack
376
+ return signal === '1' || signal === 'yes'
377
+ }
378
+
379
+ function labelFor(element: Element): string {
380
+ const aria = element.getAttribute('aria-label')
381
+ const source =
382
+ (aria && aria.trim()) ||
383
+ (element.textContent ?? '').replace(/\s+/gu, ' ').trim() ||
384
+ element.getAttribute('title') ||
385
+ element.getAttribute('href') ||
386
+ element.tagName.toLowerCase()
387
+ return source.slice(0, LABEL_MAX_LENGTH)
388
+ }
389
+
390
+ /**
391
+ * Classify a link.
392
+ *
393
+ * ORDER IS LOAD-BEARING and is asserted by tests. Protocol wins first (a `tel:` href has
394
+ * no host to match on), then booking — *before* the same-origin and social checks — so a
395
+ * self-hosted `/book` path and a booking vendor that also runs a social profile are both
396
+ * attributed to revenue rather than to `internal` / `social`.
397
+ */
398
+ export function classifyHref(
399
+ href: string,
400
+ opts: {
401
+ pageHost: string
402
+ bookingHosts: readonly string[]
403
+ bookingPaths: readonly string[]
404
+ socialHosts: readonly string[]
405
+ },
406
+ ): ConversionInteractionType {
407
+ const raw = href.trim()
408
+ if (!raw) return 'button'
409
+
410
+ const lower = raw.toLowerCase()
411
+ if (lower.startsWith('tel:') || lower.startsWith('sms:')) return 'phone'
412
+ if (lower.startsWith('mailto:')) return 'email'
413
+ // Non-navigational anchors (`#`, `javascript:`) behave like buttons.
414
+ if (lower.startsWith('#') || lower.startsWith('javascript:')) return 'button'
415
+
416
+ let url: URL
417
+ try {
418
+ url = new URL(raw, `https://${opts.pageHost || 'localhost'}/`)
419
+ } catch {
420
+ return 'internal'
421
+ }
422
+
423
+ if (hostMatches(url.hostname, opts.bookingHosts)) return 'booking'
424
+
425
+ const sameOrigin =
426
+ !!opts.pageHost && url.hostname.toLowerCase() === opts.pageHost.toLowerCase()
427
+ if (sameOrigin && opts.bookingPaths.some((p) => url.pathname.startsWith(p))) {
428
+ return 'booking'
429
+ }
430
+ if (hostMatches(url.hostname, opts.socialHosts)) return 'social'
431
+ if (sameOrigin) return 'internal'
432
+ if (url.protocol === 'http:' || url.protocol === 'https:') return 'external'
433
+ return 'external'
434
+ }
435
+
436
+ /** The object returned by {@link createConversionTracker}. */
437
+ export interface ConversionTracker {
438
+ /** Bind the capture-phase click listener. Idempotent. */
439
+ start(): void
440
+ /** Unbind and clear the buffer. Idempotent. */
441
+ stop(): void
442
+ /** Attach (or replace) the sink and immediately drain anything buffered. */
443
+ attachSink(sink: ConversionSink): void
444
+ /** Events currently held because no sink is attached. Read-only copy. */
445
+ buffered(): ConversionEvent[]
446
+ /** Capture a click target directly. Exposed for tests and manual instrumentation. */
447
+ capture(element: Element): ConversionEvent | null
448
+ }
449
+
450
+ /**
451
+ * Module-level sink registry.
452
+ *
453
+ * A lazy telemetry loader usually does not hold a reference to the tracker — it just
454
+ * finished `loadAppInsights()` and wants to start receiving. This lets it say so from
455
+ * anywhere, which is the ergonomic that makes the SDK-deferral interlock actually get used.
456
+ */
457
+ const registeredTrackers = new Set<ConversionTracker>()
458
+
459
+ /**
460
+ * Documents that already have a delegated listener bound.
461
+ *
462
+ * THE DOUBLE-COUNT RAIL. Two capture-phase listeners on one document emit two events per
463
+ * click, and the resulting number looks entirely plausible — which is why C-288 shipped
464
+ * 52% duplicate page views to a customer's owner report before anyone noticed. Since
465
+ * C-326 the tracker auto-installs, so the realistic collision is a site that ALSO follows
466
+ * the old docs and mounts `useConversionTracking()` in `App.vue`. That second `start()`
467
+ * refuses, loudly, instead of silently doubling the site's conversion count.
468
+ */
469
+ const boundDocuments = new WeakSet<Document>()
470
+ let warnedDoubleBind = false
471
+
472
+ /**
473
+ * Attach a sink to every live tracker and drain their buffers.
474
+ *
475
+ * Call this from a deferred/lazy App Insights loader once the SDK is ready:
476
+ *
477
+ * ```ts
478
+ * const { ApplicationInsights } = await import('@microsoft/applicationinsights-web')
479
+ * const ai = new ApplicationInsights({ config })
480
+ * ai.loadAppInsights()
481
+ * attachConversionSink((e) => ai.trackEvent({ name: e.name, properties: e.properties }))
482
+ * ```
483
+ *
484
+ * @returns how many trackers received the sink.
485
+ */
486
+ export function attachConversionSink(sink: ConversionSink): number {
487
+ let count = 0
488
+ for (const tracker of registeredTrackers) {
489
+ tracker.attachSink(sink)
490
+ count += 1
491
+ }
492
+ return count
493
+ }
494
+
495
+ /**
496
+ * Framework-free conversion tracker. Vue callers usually want
497
+ * {@link useConversionTracking} instead.
498
+ */
499
+ export function createConversionTracker(
500
+ options: ConversionTrackingOptions = {},
501
+ ): ConversionTracker {
502
+ const eventName = options.eventName ?? 'site_interaction'
503
+ const bookingHosts = [...DEFAULT_BOOKING_HOSTS, ...(options.bookingHosts ?? [])]
504
+ const socialHosts = [...DEFAULT_SOCIAL_HOSTS, ...(options.socialHosts ?? [])]
505
+ const bookingPaths = options.bookingPaths ?? []
506
+ const bufferLimit = Math.max(0, options.bufferLimit ?? DEFAULT_BUFFER_LIMIT)
507
+ const requireTrusted = options.requireTrusted ?? false
508
+ const captureFormSubmits = options.captureFormSubmits ?? true
509
+ const respectDoNotTrack = options.respectDoNotTrack ?? true
510
+ const force = options.force ?? false
511
+
512
+ const mirrors = options.mirrors ?? []
513
+ let sink: ConversionSink | null = options.sink ?? null
514
+ let buffer: ConversionEvent[] = []
515
+ let listening = false
516
+ let lastKey = ''
517
+ let lastAt = 0
518
+
519
+ const doc = (): Document | null =>
520
+ options.target ?? (typeof document !== 'undefined' ? document : null)
521
+
522
+ function pageHost(): string {
523
+ return typeof window !== 'undefined' ? window.location.hostname : ''
524
+ }
525
+
526
+ function pagePath(): string {
527
+ return typeof window !== 'undefined' ? window.location.pathname : ''
528
+ }
529
+
530
+ function emit(event: ConversionEvent): void {
531
+ for (const mirror of mirrors) {
532
+ try {
533
+ mirror(event)
534
+ } catch {
535
+ // A broken transport must never break the site's click handling.
536
+ }
537
+ }
538
+
539
+ if (sink) {
540
+ try {
541
+ sink(event)
542
+ } catch {
543
+ // Same contract as the mirrors.
544
+ }
545
+ return
546
+ }
547
+ // No sink yet (deferred SDK). Buffer, bounded, oldest-out. Mirrors above already
548
+ // received this event, so a GA4-only site loses nothing by never attaching a sink.
549
+ if (bufferLimit === 0) return
550
+ buffer.push(event)
551
+ if (buffer.length > bufferLimit) buffer.shift()
552
+ }
553
+
554
+ /** Assemble an event from an already-classified actionable element. */
555
+ function compose(
556
+ actionable: Element,
557
+ interactionType: ConversionInteractionType,
558
+ rawHref: string,
559
+ ): ConversionEvent {
560
+ const host = pageHost()
561
+ const { href, scheme, hash } = redactHref(rawHref, host)
562
+
563
+ let hrefHost = ''
564
+ // Contact schemes have no host, and asking for one would mean parsing the very string
565
+ // that was just redacted.
566
+ if (href && !hash) {
567
+ try {
568
+ hrefHost = new URL(href, `https://${host || 'localhost'}/`).hostname
569
+ } catch {
570
+ hrefHost = ''
571
+ }
572
+ }
573
+
574
+ return {
575
+ name: eventName,
576
+ properties: {
577
+ interaction_type: interactionType,
578
+ is_conversion: isConversionType(interactionType) ? 'true' : 'false',
579
+ label: labelFor(actionable),
580
+ href,
581
+ href_scheme: scheme,
582
+ href_host: hrefHost,
583
+ href_hash: hash,
584
+ page_path: pagePath(),
585
+ host,
586
+ capture_version: CONVERSION_CAPTURE_VERSION,
587
+ },
588
+ }
589
+ }
590
+
591
+ function build(element: Element): ConversionEvent | null {
592
+ const anchor = element.closest('a')
593
+ const actionable: Element | null = anchor ?? element.closest('button')
594
+ if (!actionable) return null
595
+
596
+ // A click on a form's submit control is NOT the event — the `submit` that follows is
597
+ // (and only follows if validation passed). Dropping it here is what keeps a single
598
+ // "Send" tap from being counted as both a `button` click and a `form_submit`.
599
+ if (captureFormSubmits && !anchor && isSubmitControl(actionable)) return null
600
+
601
+ const rawHref = anchor?.getAttribute('href') ?? ''
602
+ const interactionType = anchor
603
+ ? classifyHref(rawHref, { pageHost: pageHost(), bookingHosts, bookingPaths, socialHosts })
604
+ : 'button'
605
+
606
+ // Resolve relative hrefs through the anchor's own `.href`, but keep contact schemes on
607
+ // the raw attribute — jsdom and browsers both normalise `tel:` inconsistently.
608
+ const resolved =
609
+ anchor && !CONTACT_SCHEMES.some((s) => rawHref.trim().toLowerCase().startsWith(s))
610
+ ? (anchor as HTMLAnchorElement).href || rawHref
611
+ : rawHref
612
+
613
+ return compose(actionable, interactionType, resolved)
614
+ }
615
+
616
+ /** Build the event for a managed/bespoke form that actually submitted. */
617
+ function buildSubmit(form: Element): ConversionEvent {
618
+ // Prefer the submit control's own label ("Request a Consultation") over the form's
619
+ // full text content, which on a real intake form is every field label concatenated.
620
+ const control =
621
+ form.querySelector('button[type="submit"], input[type="submit"]') ??
622
+ form.querySelector('button') ??
623
+ form
624
+ return compose(control, 'form_submit', '')
625
+ }
626
+
627
+ /** Shared dedupe gate: collapses an identical event fired twice in quick succession. */
628
+ function emitDeduped(event: ConversionEvent): ConversionEvent | null {
629
+ const key = `${event.properties.interaction_type}|${event.properties.href}|${event.properties.label}`
630
+ const now = Date.now()
631
+ if (key === lastKey && now - lastAt < DEDUPE_WINDOW_MS) return null
632
+ lastKey = key
633
+ lastAt = now
634
+
635
+ emit(event)
636
+ return event
637
+ }
638
+
639
+ function capture(element: Element): ConversionEvent | null {
640
+ const event = build(element)
641
+ if (!event) return null
642
+ return emitDeduped(event)
643
+ }
644
+
645
+ const onClick = (event: Event): void => {
646
+ if (requireTrusted && !(event as MouseEvent).isTrusted) return
647
+ const target = event.target
648
+ if (!target || !(target as Element).closest) return
649
+ try {
650
+ capture(target as Element)
651
+ } catch {
652
+ // Never let telemetry throw inside a user's click.
653
+ }
654
+ }
655
+
656
+ const onSubmit = (event: Event): void => {
657
+ if (requireTrusted && !event.isTrusted) return
658
+ const form = event.target
659
+ if (!form || !(form as Element).tagName) return
660
+ try {
661
+ emitDeduped(buildSubmit(form as Element))
662
+ } catch {
663
+ // Never let telemetry throw inside a user's submit.
664
+ }
665
+ }
666
+
667
+ const tracker: ConversionTracker = {
668
+ start() {
669
+ if (listening) return
670
+ const d = doc()
671
+ if (!d) return
672
+
673
+ if (respectDoNotTrack && doNotTrackEnabled()) {
674
+ // Bind nothing. Not even the buffer — a visitor who asked not to be tracked should
675
+ // not have their clicks sitting in memory waiting for a sink to show up.
676
+ return
677
+ }
678
+
679
+ if (boundDocuments.has(d) && !force) {
680
+ if (!warnedDoubleBind) {
681
+ warnedDoubleBind = true
682
+ console.warn(
683
+ '[dcs-conversion] start() REFUSED: a delegated conversion listener is already ' +
684
+ 'bound to this document. Binding a second one counts every click twice — the ' +
685
+ 'defect class that put 52% duplicate page views in a live owner report ' +
686
+ '(C-288). Since C-326 the tracker auto-installs with @duffcloudservices/cms, ' +
687
+ 'so a manual useConversionTracking() in App.vue is no longer needed; remove ' +
688
+ 'it, or pass { force: true } if you have genuinely torn the first one down.',
689
+ )
690
+ }
691
+ return
692
+ }
693
+
694
+ // Capture phase: fires even when a handler downstream calls stopPropagation().
695
+ d.addEventListener('click', onClick, { capture: true })
696
+ if (captureFormSubmits) d.addEventListener('submit', onSubmit, { capture: true })
697
+ listening = true
698
+ boundDocuments.add(d)
699
+ registeredTrackers.add(tracker)
700
+ },
701
+ stop() {
702
+ const d = doc()
703
+ if (d && listening) {
704
+ d.removeEventListener('click', onClick, { capture: true })
705
+ if (captureFormSubmits) d.removeEventListener('submit', onSubmit, { capture: true })
706
+ boundDocuments.delete(d)
707
+ }
708
+ listening = false
709
+ buffer = []
710
+ registeredTrackers.delete(tracker)
711
+ },
712
+ attachSink(next: ConversionSink) {
713
+ sink = next
714
+ const pending = buffer
715
+ buffer = []
716
+ for (const event of pending) {
717
+ try {
718
+ next(event)
719
+ } catch {
720
+ // Same contract as emit(): a broken transport is not the site's problem.
721
+ }
722
+ }
723
+ },
724
+ buffered() {
725
+ return [...buffer]
726
+ },
727
+ capture,
728
+ }
729
+
730
+ return tracker
731
+ }
732
+
733
+ /**
734
+ * Vue composable: start conversion capture for the lifetime of the calling component.
735
+ *
736
+ * YOU PROBABLY DO NOT NEED THIS ANY MORE. Since C-326, importing `@duffcloudservices/cms`
737
+ * auto-installs a tracker (`installConversionCapture`), so every fleet site captures
738
+ * conversions with no site-repo code at all. Calling this on top of that is a no-op with a
739
+ * loud console warning — `start()` refuses to bind a second listener to a document that
740
+ * already has one, because two listeners double every conversion count.
741
+ *
742
+ * It remains exported for the cases the auto-install deliberately does not cover: a site
743
+ * that needs per-site `bookingHosts`/`bookingPaths`, or a non-cms consumer wiring capture
744
+ * by hand. In the first case, prefer configuring the auto-installer:
745
+ *
746
+ * ```ts
747
+ * import { installConversionCapture } from '@duffcloudservices/cms'
748
+ * installConversionCapture({ bookingHosts: ['stridethera.com'], bookingPaths: ['/book'] })
749
+ * ```
750
+ *
751
+ * Returns the tracker so a caller can attach a sink or inspect the buffer. Safe on the
752
+ * server: with no `document`, `start()` is a no-op.
753
+ */
754
+ export function useConversionTracking(
755
+ options: ConversionTrackingOptions = {},
756
+ ): ConversionTracker {
757
+ const tracker = createConversionTracker(options)
758
+
759
+ // Inside a component: bind to its lifecycle. Outside one (plain-JS site entry, a
760
+ // module-level call), start immediately — SSR/prerender is covered because `start()`
761
+ // no-ops without a `document`.
762
+ if (getCurrentInstance()) {
763
+ onMounted(() => tracker.start())
764
+ onBeforeUnmount(() => tracker.stop())
765
+ } else {
766
+ tracker.start()
767
+ }
768
+
769
+ return tracker
770
+ }