@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.
- package/README.md +244 -8
- package/dist/chunk-A5F4C72F.js +500 -0
- package/dist/chunk-A5F4C72F.js.map +1 -0
- package/dist/{chunk-F3EIWEZD.js → chunk-HVSF23P7.js} +971 -73
- package/dist/chunk-HVSF23P7.js.map +1 -0
- package/dist/editor/editorBridge.d.ts +13 -1
- package/dist/editor/editorBridge.js +75 -5
- package/dist/editor/editorBridge.js.map +1 -1
- package/dist/headHonesty-OzxvLuwd.d.ts +222 -0
- package/dist/index.d.ts +365 -22
- package/dist/index.js +421 -21
- package/dist/index.js.map +1 -1
- package/dist/installSeoHead-kWQwObez.d.ts +627 -0
- package/dist/plugins/index.d.ts +90 -6
- package/dist/plugins/index.js +530 -49
- package/dist/plugins/index.js.map +1 -1
- package/dist/seo/index.d.ts +763 -4
- package/dist/seo/index.js +2 -2
- package/dist/{vitepressTransform-DfmABXmK.d.ts → vitepressTransform-JG_zlaux.d.ts} +99 -6
- package/package.json +17 -6
- package/src/components/DcsCallButton.test.ts +58 -0
- package/src/components/DcsCallButton.vue +19 -4
- package/src/components/LiteMediaEmbed.vue +3 -3
- package/src/components/ManagedImage.test.ts +34 -0
- package/src/components/ManagedImage.vue +5 -0
- package/src/components/PreviewRibbon.vue +4 -1
- package/src/composables/useConversionTracking.test.ts +492 -0
- package/src/composables/useConversionTracking.ts +770 -0
- package/src/composables/useReleaseNotes.ts +7 -1
- package/src/composables/useSEO.applyHead.test.ts +150 -0
- package/src/composables/useSEO.ts +63 -17
- package/src/composables/useSiteVersion.ts +4 -1
- package/src/composables/useSiteVisitorSession.test.ts +56 -0
- package/src/composables/useSiteVisitorSession.ts +39 -3
- package/src/composables/useTextContent.ts +9 -1
- package/dist/chunk-DAYLLSEE.js +0 -3
- package/dist/chunk-DAYLLSEE.js.map +0 -1
- package/dist/chunk-F3EIWEZD.js.map +0 -1
- 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
|
+
}
|