@web-my-money/studio-consumer 1.0.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 +19 -0
- package/package.json +27 -0
- package/src/analytics/bucketing.ts +101 -0
- package/src/analytics/collect-handler.ts +126 -0
- package/src/analytics/collector.ts +537 -0
- package/src/analytics/components.tsx +382 -0
- package/src/analytics/index.ts +37 -0
- package/src/analytics/proxy.ts +87 -0
- package/src/analytics/use-form-analytics.ts +256 -0
- package/src/attribution/crm.ts +20 -0
- package/src/attribution/index.ts +14 -0
- package/src/attribution/store.ts +225 -0
- package/src/content/dict-overrides.ts +101 -0
- package/src/content/headers.d.ts +11 -0
- package/src/content/headers.mjs +40 -0
- package/src/content/index.ts +27 -0
- package/src/content/manifest-handler.ts +34 -0
- package/src/content/payload.ts +414 -0
- package/src/content/preview.tsx +550 -0
- package/src/content/revalidate-handler.ts +103 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-party funnel-analytics collector (wmm-studio docs/analytics/01-pipeline.md §5.2).
|
|
3
|
+
*
|
|
4
|
+
* Events go to a same-origin route (`/api/analytics/collect`), which forwards
|
|
5
|
+
* them server-side to Studio's ingest endpoint. That indirection is the whole
|
|
6
|
+
* point: the ingest token never reaches a browser, and the request the visitor's
|
|
7
|
+
* browser makes is same-origin, so ad-blockers and third-party cookie policy
|
|
8
|
+
* don't touch it.
|
|
9
|
+
*
|
|
10
|
+
* This is a THIRD sink alongside `dataLayer` (GTM → GA4/Meta) and the GHL event
|
|
11
|
+
* buffer, and it follows the same rules as `lib/site-events.ts`: additive,
|
|
12
|
+
* fire-and-forget, and it swallows every error it can produce. GA4 answers "how
|
|
13
|
+
* much traffic"; this answers "which step of our own form did they leave on",
|
|
14
|
+
* which nothing off-the-shelf can see.
|
|
15
|
+
*
|
|
16
|
+
* Inert unless `NEXT_PUBLIC_FUNNEL_ANALYTICS === "1"`, so it can ship dark and
|
|
17
|
+
* be switched on per environment.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { getAttribution } from "../attribution/store";
|
|
21
|
+
import { resolveVariant, type RunningExperiment } from "./bucketing";
|
|
22
|
+
|
|
23
|
+
const VISITOR_KEY = "wmm_funnel_visitor";
|
|
24
|
+
const SESSION_KEY = "wmm_funnel_session";
|
|
25
|
+
/** Same key the med-spa funnel and site-events use — one definition of "staff". */
|
|
26
|
+
const INTERNAL_FLAG_KEY = "wmm_internal_traffic";
|
|
27
|
+
|
|
28
|
+
/** 30 minutes of inactivity ends a session (funnel-analytics-plan §9). */
|
|
29
|
+
const SESSION_IDLE_MS = 30 * 60 * 1000;
|
|
30
|
+
const FLUSH_INTERVAL_MS = 5_000;
|
|
31
|
+
/** Matches the ingest endpoint's batch cap; a fuller queue flushes early. */
|
|
32
|
+
const MAX_QUEUE = 50;
|
|
33
|
+
/**
|
|
34
|
+
* The same-origin route events are posted to.
|
|
35
|
+
*
|
|
36
|
+
* A constant, not configuration. The route is half of a pair — this and the
|
|
37
|
+
* `createCollectHandler` mounted behind it — and the package documents mounting
|
|
38
|
+
* it at exactly this path. A mutable module-level override with a public setter
|
|
39
|
+
* bought nothing (no caller in either repo used it) and could be called AFTER
|
|
40
|
+
* events were queued, silently redirecting a site's analytics mid-session.
|
|
41
|
+
*/
|
|
42
|
+
const COLLECT_URL = "/api/analytics/collect";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The running experiments, handed over once by `<AbArm>` in the locale layout.
|
|
46
|
+
*
|
|
47
|
+
* Module-level rather than React state because `trackFunnelEvent` is called from
|
|
48
|
+
* places that are not components — a lifecycle listener, a submit handler, the
|
|
49
|
+
* scroll observer. The list is published content, fixed for the life of a
|
|
50
|
+
* deploy, so there is nothing to keep in sync.
|
|
51
|
+
*/
|
|
52
|
+
let runningExperiments: RunningExperiment[] = [];
|
|
53
|
+
|
|
54
|
+
/** Called once, from the layout. Safe to call again; the last list wins. */
|
|
55
|
+
export function registerExperiments(list: RunningExperiment[] | null | undefined): void {
|
|
56
|
+
runningExperiments = list ?? [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The A/B arm to stamp on an event, or `undefined` when no test applies.
|
|
61
|
+
*
|
|
62
|
+
* Resolved per event from the CURRENT path, not once per page load, because a
|
|
63
|
+
* client-side navigation moves the visitor between a page under test and one
|
|
64
|
+
* that is not, and the layout that mounted `<AbArm>` does not re-render when it
|
|
65
|
+
* happens. Same function, same cookie and same list as the server used to choose
|
|
66
|
+
* the copy, so the arm recorded is the arm rendered.
|
|
67
|
+
*
|
|
68
|
+
* `undefined` unless an experiment actually targets this path — `resolveVariant`
|
|
69
|
+
* answers `control` for "no test here", and writing that into the column would
|
|
70
|
+
* make every ordinary page view look like a control-arm measurement. What the
|
|
71
|
+
* comparison needs is the opposite: `variant` set ONLY where a test was running.
|
|
72
|
+
*/
|
|
73
|
+
function armFor(path: string): string | undefined {
|
|
74
|
+
if (runningExperiments.length === 0) return undefined;
|
|
75
|
+
try {
|
|
76
|
+
const { variant, experimentKey } = resolveVariant(
|
|
77
|
+
path,
|
|
78
|
+
cookieVisitorId(),
|
|
79
|
+
runningExperiments,
|
|
80
|
+
);
|
|
81
|
+
return experimentKey ? variant : undefined;
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A page being previewed inside Studio, rather than visited.
|
|
89
|
+
*
|
|
90
|
+
* Studio's click-to-edit workspace frames the real page, so without this every
|
|
91
|
+
* editing session writes page views, scroll depth and clicks that look exactly
|
|
92
|
+
* like visitor traffic — and `?wmm-variant=b` would write them into the arm
|
|
93
|
+
* under test, which is worse than not measuring at all. The rollups already
|
|
94
|
+
* exclude `internal` sessions, so this is the flag that keeps an editor out of
|
|
95
|
+
* their own numbers.
|
|
96
|
+
*/
|
|
97
|
+
function isStudioPreview(): boolean {
|
|
98
|
+
try {
|
|
99
|
+
const params = new URLSearchParams(window.location?.search ?? "");
|
|
100
|
+
return params.has("wmm-edit") || params.has("wmm-variant");
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type FunnelEventProps = {
|
|
107
|
+
path?: string;
|
|
108
|
+
formKey?: string;
|
|
109
|
+
stepId?: string;
|
|
110
|
+
stepIndex?: number;
|
|
111
|
+
msOnStep?: number;
|
|
112
|
+
/** A field NAME, never its value. See 07-consent-retention.md §2. */
|
|
113
|
+
field?: string;
|
|
114
|
+
/** Why a field errored ("required", "invalid"), never what was typed. */
|
|
115
|
+
errorKind?: string;
|
|
116
|
+
/** A number carried by the event — a scroll threshold, a count. */
|
|
117
|
+
value?: number;
|
|
118
|
+
/**
|
|
119
|
+
* What was interacted with: a CTA name or a link target. A target NAME, same
|
|
120
|
+
* rule as `field` — never an element's innerText, which on a personalised page
|
|
121
|
+
* can contain someone's name or email.
|
|
122
|
+
*/
|
|
123
|
+
label?: string;
|
|
124
|
+
/*
|
|
125
|
+
No `variant` here on purpose. It used to be a caller-supplied string, and
|
|
126
|
+
`StudioForm` passed `config.variant` — the form's CSS SKIN name — straight
|
|
127
|
+
into it. So the one column the A/B comparison reads held the value "medspa"
|
|
128
|
+
on 109 events and null on the other 942, and the arm was never recorded at
|
|
129
|
+
all: an experiment could run to completion and report "no difference",
|
|
130
|
+
correctly, forever. The arm is stamped by `armFor()` below, from the same
|
|
131
|
+
function the server rendered with, and no caller can shadow it.
|
|
132
|
+
*/
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
type QueuedEvent = FunnelEventProps & {
|
|
136
|
+
/** The A/B arm, stamped here rather than accepted from a caller. */
|
|
137
|
+
variant?: string;
|
|
138
|
+
gaClientId?: string;
|
|
139
|
+
gaSessionId?: string;
|
|
140
|
+
eventUid: string;
|
|
141
|
+
visitorId: string;
|
|
142
|
+
sessionId: string;
|
|
143
|
+
name: string;
|
|
144
|
+
path: string;
|
|
145
|
+
utm?: Record<string, string>;
|
|
146
|
+
referrer?: string;
|
|
147
|
+
deviceType?: string;
|
|
148
|
+
viewport?: number;
|
|
149
|
+
locale?: string;
|
|
150
|
+
internal?: boolean;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
function isBrowser(): boolean {
|
|
154
|
+
return typeof window !== "undefined";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function funnelAnalyticsEnabled(): boolean {
|
|
158
|
+
return process.env.NEXT_PUBLIC_FUNNEL_ANALYTICS === "1";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Consent gate (wmm-studio docs/analytics/07-consent-retention.md §3).
|
|
163
|
+
*
|
|
164
|
+
* `NEXT_PUBLIC_FUNNEL_CONSENT`:
|
|
165
|
+
*
|
|
166
|
+
* - unset / `"off"` — no gate. This is the DEFAULT and it deliberately matches
|
|
167
|
+
* what the rest of this site already does: GA4, GTM, the Meta Pixel and
|
|
168
|
+
* Clarity all run today with no banner. Holding our own first-party analytics
|
|
169
|
+
* to a stricter rule than the third-party tags on the same page would cost
|
|
170
|
+
* measurement without changing the site's actual privacy posture.
|
|
171
|
+
* - `"required"` — FAIL CLOSED. Nothing is sent until consent is explicitly
|
|
172
|
+
* granted. Absent, unknown or malformed all count as "not granted".
|
|
173
|
+
*
|
|
174
|
+
* The state is read from a `wmm_consent` cookie (value `granted`), so a banner
|
|
175
|
+
* added later only has to write that cookie — there is no second consent model
|
|
176
|
+
* here to contradict the one the site eventually adopts.
|
|
177
|
+
*
|
|
178
|
+
* Which mode is correct is a decision about lawful basis, not a coding choice.
|
|
179
|
+
* It is recorded as an open question in 07 §3.1 rather than assumed here.
|
|
180
|
+
*/
|
|
181
|
+
export function consentGranted(): boolean {
|
|
182
|
+
if (process.env.NEXT_PUBLIC_FUNNEL_CONSENT !== "required") return true;
|
|
183
|
+
if (!isBrowser()) return false;
|
|
184
|
+
try {
|
|
185
|
+
return /(?:^|;\s*)wmm_consent=granted(?:;|$)/.test(document.cookie);
|
|
186
|
+
} catch {
|
|
187
|
+
// Unreadable cookies means unknown consent, and unknown fails closed.
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- Storage, with an in-memory fallback ------------------------------------
|
|
193
|
+
// Safari private mode throws on localStorage. Probe once and remember: probing
|
|
194
|
+
// per call would return null on read and succeed on the memory write, which
|
|
195
|
+
// mints a fresh visitor id on every single event.
|
|
196
|
+
|
|
197
|
+
let storageWorks: boolean | null = null;
|
|
198
|
+
const memory = new Map<string, string>();
|
|
199
|
+
|
|
200
|
+
function storageAvailable(): boolean {
|
|
201
|
+
if (storageWorks !== null) return storageWorks;
|
|
202
|
+
try {
|
|
203
|
+
window.localStorage.setItem("wmm_funnel_probe", "1");
|
|
204
|
+
window.localStorage.removeItem("wmm_funnel_probe");
|
|
205
|
+
storageWorks = true;
|
|
206
|
+
} catch {
|
|
207
|
+
storageWorks = false;
|
|
208
|
+
}
|
|
209
|
+
return storageWorks;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function readStore(key: string): string | null {
|
|
213
|
+
if (!storageAvailable()) return memory.get(key) ?? null;
|
|
214
|
+
try {
|
|
215
|
+
return window.localStorage.getItem(key);
|
|
216
|
+
} catch {
|
|
217
|
+
return memory.get(key) ?? null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function writeStore(key: string, value: string): void {
|
|
222
|
+
if (!storageAvailable()) {
|
|
223
|
+
memory.set(key, value);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
window.localStorage.setItem(key, value);
|
|
228
|
+
} catch {
|
|
229
|
+
memory.set(key, value);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The ingest endpoint validates `eventUid` as a uuid, so the fallback must be one. */
|
|
234
|
+
function uuid(): string {
|
|
235
|
+
try {
|
|
236
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
237
|
+
return crypto.randomUUID();
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
/* fall through */
|
|
241
|
+
}
|
|
242
|
+
const hex = "0123456789abcdef";
|
|
243
|
+
let out = "";
|
|
244
|
+
for (let i = 0; i < 36; i++) {
|
|
245
|
+
if (i === 8 || i === 13 || i === 18 || i === 23) out += "-";
|
|
246
|
+
else if (i === 14) out += "4";
|
|
247
|
+
else if (i === 19) out += hex[(Math.random() * 4) | 8]!;
|
|
248
|
+
else out += hex[(Math.random() * 16) | 0]!;
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The `wmm_vid` cookie the middleware sets, or null.
|
|
255
|
+
*
|
|
256
|
+
* The cookie is the AUTHORITATIVE visitor identity, because it is the only one a
|
|
257
|
+
* server can read — and A/B assignment has to happen server-side to avoid a flash
|
|
258
|
+
* of the wrong variant (lib/experiments.ts). Preferring it here is what stops the
|
|
259
|
+
* server and the browser disagreeing about who someone is, which would put a
|
|
260
|
+
* visitor in one bucket and file their events under another.
|
|
261
|
+
*/
|
|
262
|
+
function cookieVisitorId(): string | null {
|
|
263
|
+
try {
|
|
264
|
+
const match = document.cookie.match(/(?:^|;\s*)wmm_vid=([^;]+)/);
|
|
265
|
+
return match ? decodeURIComponent(match[1]!) : null;
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function visitorId(): string {
|
|
272
|
+
const fromCookie = cookieVisitorId();
|
|
273
|
+
if (fromCookie) return fromCookie;
|
|
274
|
+
|
|
275
|
+
// No cookie: middleware has not run for this document (a cached shell, a route
|
|
276
|
+
// it does not match). Fall back to storage so events are still attributable.
|
|
277
|
+
const existing = readStore(VISITOR_KEY);
|
|
278
|
+
if (existing) return existing;
|
|
279
|
+
const fresh = uuid();
|
|
280
|
+
writeStore(VISITOR_KEY, fresh);
|
|
281
|
+
return fresh;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Session id on a 30-minute idle window. Stored with its own last-seen stamp
|
|
286
|
+
* rather than in sessionStorage, because a session must survive a tab reload
|
|
287
|
+
* and must expire on idleness — sessionStorage does the opposite of both.
|
|
288
|
+
*/
|
|
289
|
+
function sessionId(now: number): string {
|
|
290
|
+
const raw = readStore(SESSION_KEY);
|
|
291
|
+
if (raw) {
|
|
292
|
+
try {
|
|
293
|
+
const parsed = JSON.parse(raw) as { id?: string; lastSeen?: number };
|
|
294
|
+
if (
|
|
295
|
+
typeof parsed.id === "string" &&
|
|
296
|
+
typeof parsed.lastSeen === "number" &&
|
|
297
|
+
now - parsed.lastSeen < SESSION_IDLE_MS
|
|
298
|
+
) {
|
|
299
|
+
writeStore(SESSION_KEY, JSON.stringify({ id: parsed.id, lastSeen: now }));
|
|
300
|
+
return parsed.id;
|
|
301
|
+
}
|
|
302
|
+
} catch {
|
|
303
|
+
/* corrupt value — mint a new session below */
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const fresh = uuid();
|
|
307
|
+
writeStore(SESSION_KEY, JSON.stringify({ id: fresh, lastSeen: now }));
|
|
308
|
+
return fresh;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* GA4's client id, from the `_ga` cookie it already sets.
|
|
313
|
+
*
|
|
314
|
+
* The cookie reads `GA1.1.1234567890.1699999999`; GA4's own client id is the last
|
|
315
|
+
* two segments joined — that exact string is what the Measurement Protocol needs
|
|
316
|
+
* to attach an event to the right user, and there is no way to reconstruct it
|
|
317
|
+
* afterwards.
|
|
318
|
+
*
|
|
319
|
+
* Reading it costs nothing and makes our numbers reconcilable with GA4's per
|
|
320
|
+
* visitor instead of only in aggregate.
|
|
321
|
+
*/
|
|
322
|
+
function gaClientId(): string | undefined {
|
|
323
|
+
try {
|
|
324
|
+
const match = document.cookie.match(/(?:^|;\s*)_ga=GA\d+\.\d+\.([\d.]+)/);
|
|
325
|
+
return match ? match[1] : undefined;
|
|
326
|
+
} catch {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* GA4's session id, from the per-stream `_ga_<MEASUREMENT_ID>` cookie.
|
|
333
|
+
*
|
|
334
|
+
* Matched by prefix because the suffix is the measurement id, which is a Studio
|
|
335
|
+
* -editable tracking value — hardcoding it here would break the moment someone
|
|
336
|
+
* changes the GA4 property, silently.
|
|
337
|
+
*
|
|
338
|
+
* Google has shipped two value layouts and a container can be on either:
|
|
339
|
+
*
|
|
340
|
+
* GS1.1.1699999999.1.0.1699999999.0.0.0 ← session id is the 3rd segment
|
|
341
|
+
* GS2.1.s1699999999$o3$g1$t1699999999$j45$l0 ← session id follows an `s`
|
|
342
|
+
*
|
|
343
|
+
* Both are read, because getting this wrong is not a degraded feature — it is a
|
|
344
|
+
* wrong number. Studio requires a GA4 session id before it will forward a form
|
|
345
|
+
* event to the Measurement Protocol, precisely because an MP event without one
|
|
346
|
+
* starts a **new** GA4 session, and an MP-started session has no referrer and no
|
|
347
|
+
* gclid, so GA4 files it as (direct)/(none). Returning undefined here therefore
|
|
348
|
+
* means "no GA4 forwarding"; returning a value parsed out of the wrong layout
|
|
349
|
+
* would mean fabricated sessions in someone's acquisition report.
|
|
350
|
+
*/
|
|
351
|
+
function gaSessionId(): string | undefined {
|
|
352
|
+
try {
|
|
353
|
+
const cookie = document.cookie.match(/(?:^|;\s*)_ga_[A-Z0-9]+=(GS[^;]+)/);
|
|
354
|
+
if (!cookie) return undefined;
|
|
355
|
+
const value = cookie[1]!;
|
|
356
|
+
// Try the current layout first; it is the one a new container gets.
|
|
357
|
+
const current = value.match(/^GS\d+\.\d+\.s(\d+)/);
|
|
358
|
+
if (current) return current[1];
|
|
359
|
+
const legacy = value.match(/^GS\d+\.\d+\.(\d+)/);
|
|
360
|
+
return legacy ? legacy[1] : undefined;
|
|
361
|
+
} catch {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isInternal(): boolean {
|
|
367
|
+
return readStore(INTERNAL_FLAG_KEY) === "1";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function deviceType(width: number): string {
|
|
371
|
+
if (width < 768) return "mobile";
|
|
372
|
+
if (width < 1024) return "tablet";
|
|
373
|
+
return "desktop";
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Last-touch campaign params, from the one attribution store the site already keeps. */
|
|
377
|
+
function utm(): Record<string, string> | undefined {
|
|
378
|
+
try {
|
|
379
|
+
const last = getAttribution()?.last;
|
|
380
|
+
if (!last) return undefined;
|
|
381
|
+
const out: Record<string, string> = {};
|
|
382
|
+
for (const [k, v] of Object.entries(last)) {
|
|
383
|
+
if (k.startsWith("utm_") && typeof v === "string" && v) out[k.slice(4)] = v;
|
|
384
|
+
}
|
|
385
|
+
return Object.keys(out).length ? out : undefined;
|
|
386
|
+
} catch {
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// --- Queue ------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
let queue: QueuedEvent[] = [];
|
|
394
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
395
|
+
let listenersBound = false;
|
|
396
|
+
|
|
397
|
+
function send(batch: QueuedEvent[]): void {
|
|
398
|
+
if (batch.length === 0) return;
|
|
399
|
+
const body = JSON.stringify({ events: batch });
|
|
400
|
+
|
|
401
|
+
// `sendBeacon` first: it is the only path that survives the page going away,
|
|
402
|
+
// which is exactly when an abandon event is emitted.
|
|
403
|
+
try {
|
|
404
|
+
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
|
|
405
|
+
const blob = new Blob([body], { type: "application/json" });
|
|
406
|
+
if (navigator.sendBeacon(COLLECT_URL, blob)) return;
|
|
407
|
+
}
|
|
408
|
+
} catch {
|
|
409
|
+
/* fall through to fetch */
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
void fetch(COLLECT_URL, {
|
|
414
|
+
method: "POST",
|
|
415
|
+
headers: { "Content-Type": "application/json" },
|
|
416
|
+
body,
|
|
417
|
+
keepalive: true,
|
|
418
|
+
}).catch(() => {
|
|
419
|
+
/* analytics must never surface an error to the visitor */
|
|
420
|
+
});
|
|
421
|
+
} catch {
|
|
422
|
+
/* nothing further to try — drop the batch */
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function flushFunnelEvents(): void {
|
|
427
|
+
if (!consentGranted()) {
|
|
428
|
+
// Drop what is queued rather than holding it for a later grant: sending it
|
|
429
|
+
// afterwards would be sending data gathered without a basis.
|
|
430
|
+
queue = [];
|
|
431
|
+
if (timer) {
|
|
432
|
+
clearTimeout(timer);
|
|
433
|
+
timer = null;
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (timer) {
|
|
438
|
+
clearTimeout(timer);
|
|
439
|
+
timer = null;
|
|
440
|
+
}
|
|
441
|
+
const batch = queue;
|
|
442
|
+
queue = [];
|
|
443
|
+
send(batch);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function bindLifecycle(): void {
|
|
447
|
+
if (listenersBound || !isBrowser() || typeof document === "undefined") return;
|
|
448
|
+
listenersBound = true;
|
|
449
|
+
// `visibilitychange`, NOT `unload`: unload does not fire reliably on mobile
|
|
450
|
+
// Safari, which is precisely where abandons happen.
|
|
451
|
+
document.addEventListener("visibilitychange", () => {
|
|
452
|
+
if (document.visibilityState === "hidden") flushFunnelEvents();
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Queue one funnel event. Never throws, never blocks, and does nothing at all
|
|
458
|
+
* when the collector is switched off.
|
|
459
|
+
*/
|
|
460
|
+
export function trackFunnelEvent(name: string, props: FunnelEventProps = {}): void {
|
|
461
|
+
if (!isBrowser() || !name || !funnelAnalyticsEnabled()) return;
|
|
462
|
+
// Checked at queue time AND before each flush: consent revoked mid-session
|
|
463
|
+
// must stop the queue leaving, not merely stop new events joining it.
|
|
464
|
+
if (!consentGranted()) return;
|
|
465
|
+
try {
|
|
466
|
+
const now = Date.now();
|
|
467
|
+
const width = typeof window.innerWidth === "number" ? window.innerWidth : 0;
|
|
468
|
+
|
|
469
|
+
queue.push({
|
|
470
|
+
eventUid: uuid(),
|
|
471
|
+
visitorId: visitorId(),
|
|
472
|
+
sessionId: sessionId(now),
|
|
473
|
+
name,
|
|
474
|
+
path: props.path ?? window.location?.pathname ?? "/",
|
|
475
|
+
...props,
|
|
476
|
+
// After `...props`, not before: the arm is not a caller's opinion.
|
|
477
|
+
variant: armFor(props.path ?? window.location?.pathname ?? "/"),
|
|
478
|
+
utm: utm(),
|
|
479
|
+
referrer: document?.referrer || undefined,
|
|
480
|
+
deviceType: width ? deviceType(width) : undefined,
|
|
481
|
+
viewport: width || undefined,
|
|
482
|
+
locale: document?.documentElement?.lang || undefined,
|
|
483
|
+
internal: isInternal() || isStudioPreview() || undefined,
|
|
484
|
+
gaClientId: gaClientId(),
|
|
485
|
+
gaSessionId: gaSessionId(),
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
bindLifecycle();
|
|
489
|
+
|
|
490
|
+
if (queue.length >= MAX_QUEUE) {
|
|
491
|
+
flushFunnelEvents();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (!timer) timer = setTimeout(flushFunnelEvents, FLUSH_INTERVAL_MS);
|
|
495
|
+
} catch {
|
|
496
|
+
/* a tracking bug must not break a page */
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The current session id, or null — READ ONLY.
|
|
502
|
+
*
|
|
503
|
+
* Used by the lead submit path to stamp `WMM Session ID` on the GHL contact, so
|
|
504
|
+
* a lead can be traced back to the page, variant and source that produced it
|
|
505
|
+
* (wmm-studio docs/analytics/03-crm-join.md §3).
|
|
506
|
+
*
|
|
507
|
+
* Deliberately does NOT mint a session. An id minted here would be stamped on a
|
|
508
|
+
* contact while matching no row in `funnel_events` — a session that exists in the
|
|
509
|
+
* CRM and nowhere else, which is worse than an empty field because it looks like
|
|
510
|
+
* a resolvable lead that never resolves.
|
|
511
|
+
*/
|
|
512
|
+
export function currentSessionId(): string | null {
|
|
513
|
+
if (!isBrowser()) return null;
|
|
514
|
+
try {
|
|
515
|
+
const raw = readStore(SESSION_KEY);
|
|
516
|
+
if (!raw) return null;
|
|
517
|
+
const parsed = JSON.parse(raw) as { id?: string; lastSeen?: number };
|
|
518
|
+
if (typeof parsed.id !== "string" || typeof parsed.lastSeen !== "number") {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
// An expired session is not the one that produced this submit.
|
|
522
|
+
if (Date.now() - parsed.lastSeen >= SESSION_IDLE_MS) return null;
|
|
523
|
+
return parsed.id;
|
|
524
|
+
} catch {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Test-only: drop queue, timer and cached storage probe. */
|
|
530
|
+
export function resetFunnelAnalytics(): void {
|
|
531
|
+
if (timer) clearTimeout(timer);
|
|
532
|
+
timer = null;
|
|
533
|
+
queue = [];
|
|
534
|
+
listenersBound = false;
|
|
535
|
+
storageWorks = null;
|
|
536
|
+
memory.clear();
|
|
537
|
+
}
|