@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,382 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { trackFunnelEvent, registerExperiments } from "./collector";
|
|
6
|
+
import type { RunningExperiment } from "./bucketing";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Emits `page_view` into the first-party funnel collector, once per path.
|
|
10
|
+
*
|
|
11
|
+
* This exists because nothing else does it. GTM has its own page-view tag, and
|
|
12
|
+
* `lib/analytics.ts`'s `track()` only mirrors events that some component
|
|
13
|
+
* explicitly fires — no component fires a page view. Without this, every metric
|
|
14
|
+
* in the rollups that divides by visitors (conversion rate, most obviously) has
|
|
15
|
+
* a zero denominator, and the funnel report is empty however many forms are
|
|
16
|
+
* filled in.
|
|
17
|
+
*
|
|
18
|
+
* Mounted once in the locale layout, so it covers every page including ones with
|
|
19
|
+
* no form on them. Deliberately does NOT touch attribution capture: that system
|
|
20
|
+
* has its own documented invariants (docs/forms-tracking-attribution.md) and
|
|
21
|
+
* money riding on it, so widening where it runs is a separate decision from
|
|
22
|
+
* counting page views.
|
|
23
|
+
*
|
|
24
|
+
* Inert unless the collector is switched on, and it renders nothing.
|
|
25
|
+
*/
|
|
26
|
+
export function FunnelAnalytics() {
|
|
27
|
+
const pathname = usePathname();
|
|
28
|
+
// Guards against a double emit from React's development double-invoke and from
|
|
29
|
+
// a re-render that does not change the path.
|
|
30
|
+
const lastPath = useRef<string | null>(null);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!pathname || lastPath.current === pathname) return;
|
|
34
|
+
lastPath.current = pathname;
|
|
35
|
+
trackFunnelEvent("page_view", { path: pathname });
|
|
36
|
+
}, [pathname]);
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Scroll depth and click tracking, into our own event store.
|
|
43
|
+
*
|
|
44
|
+
* Why this is not already covered by what we run:
|
|
45
|
+
*
|
|
46
|
+
* - **GA4** records a single `scroll` event at 90% and nothing before it, so
|
|
47
|
+
* "half the page" is invisible.
|
|
48
|
+
* - **Clarity** has a scroll heatmap, which is excellent for *looking* at one
|
|
49
|
+
* page and useless for *counting* — it cannot tell you that people who reach
|
|
50
|
+
* 75% convert at three times the rate of people who don't, because it has no
|
|
51
|
+
* idea which visitors became leads.
|
|
52
|
+
*
|
|
53
|
+
* That join is the whole point: these events land in `studio.funnel_events`
|
|
54
|
+
* beside the form steps and the CRM outcome, keyed by the same session.
|
|
55
|
+
*
|
|
56
|
+
* Same rules as the rest of the collector: never throws, never blocks, and does
|
|
57
|
+
* nothing at all when the collector is off or consent is not granted.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Milestones, every 10%.
|
|
62
|
+
*
|
|
63
|
+
* Four buckets showed shape; ten shows a curve. The cost is bounded — at most ten
|
|
64
|
+
* events per page view, and they coalesce into the same 5s flush as everything
|
|
65
|
+
* else — and it is what lets the dashboard draw a drop-off line rather than four
|
|
66
|
+
* bars.
|
|
67
|
+
*/
|
|
68
|
+
export const THRESHOLDS = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] as const;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Which thresholds this scroll position newly crosses.
|
|
72
|
+
*
|
|
73
|
+
* Pulled out and exported because this is where the bug would live: firing a
|
|
74
|
+
* threshold twice inflates the count, and skipping one when a visitor jumps
|
|
75
|
+
* (anchor link, keyboard End) loses it entirely. Returns every threshold at or
|
|
76
|
+
* below the position that has not fired yet, so a jump to the bottom reports all
|
|
77
|
+
* four rather than only 90.
|
|
78
|
+
*/
|
|
79
|
+
export function crossedThresholds(pct: number, fired: Set<number>): number[] {
|
|
80
|
+
if (!Number.isFinite(pct)) return [];
|
|
81
|
+
return THRESHOLDS.filter((t) => pct >= t && !fired.has(t));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A label for what was clicked.
|
|
86
|
+
*
|
|
87
|
+
* Order matters. `data-analytics` is authored, so it is stable across a redesign
|
|
88
|
+
* and safe by construction. Everything after it is a fallback.
|
|
89
|
+
*
|
|
90
|
+
* innerText is deliberately NEVER used: on a personalised page a button can read
|
|
91
|
+
* "Continue as jane@example.com", and that would put an email in the analytics
|
|
92
|
+
* store. Hrefs are stripped of query and hash for the same reason — a share or
|
|
93
|
+
* unsubscribe link can carry an identifier.
|
|
94
|
+
*/
|
|
95
|
+
/**
|
|
96
|
+
* Tailwind's first segments, plus the bare utilities that have no dash.
|
|
97
|
+
*
|
|
98
|
+
* A heuristic, and openly one — but a far more stable one than enumerating every
|
|
99
|
+
* utility, because Tailwind's vocabulary grows in the value half (`rounded-xl`,
|
|
100
|
+
* `rounded-3xl`) and almost never in the property half. Checked against the
|
|
101
|
+
* segment before the first dash, so `rounded-xl` is rejected by `rounded` while an
|
|
102
|
+
* authored `cta-primary` survives.
|
|
103
|
+
*/
|
|
104
|
+
const UTILITY_SEGMENTS = new Set([
|
|
105
|
+
"p","px","py","pt","pr","pb","pl","m","mx","my","mt","mr","mb","ml",
|
|
106
|
+
"w","h","min","max","size","aspect",
|
|
107
|
+
"text","bg","border","rounded","shadow","ring","outline","divide","fill","stroke",
|
|
108
|
+
"flex","grid","col","row","gap","space","items","justify","self","order","place","content",
|
|
109
|
+
"top","left","right","bottom","inset","z","float","clear",
|
|
110
|
+
"font","leading","tracking","whitespace","break","list","indent","align","decoration",
|
|
111
|
+
"opacity","cursor","overflow","object","pointer","select","resize","scroll","snap","touch",
|
|
112
|
+
"transition","duration","delay","ease","animate","transform","scale","rotate","translate","skew","origin",
|
|
113
|
+
"backdrop","blur","brightness","contrast","grayscale","invert","saturate","sepia","filter","mix",
|
|
114
|
+
"from","via","to","bs","table","caption","sr","will","isolation",
|
|
115
|
+
// Variants and breakpoints, which also appear as the first segment.
|
|
116
|
+
"sm","md","lg","xl","hover","focus","active","visited","disabled","group","peer","dark","motion","print",
|
|
117
|
+
// Bare utilities with no dash at all.
|
|
118
|
+
"block","inline","hidden","absolute","relative","fixed","sticky","static","flow",
|
|
119
|
+
"container","truncate","uppercase","lowercase","capitalize","italic","underline",
|
|
120
|
+
"antialiased","invisible","visible","overline","subpixel",
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Does this class name look like something a person chose as a NAME, rather than
|
|
125
|
+
* as styling?
|
|
126
|
+
*
|
|
127
|
+
* The original rule was "use the first class", and a real session produced
|
|
128
|
+
* `button:px-2.5`, `button:group`, `button:rounded-xl` and `button:absolute` —
|
|
129
|
+
* identical across unrelated buttons and changing on every restyle, so two
|
|
130
|
+
* different buttons collapsed into one row in the click report. Worse than no
|
|
131
|
+
* label, because the row still looks meaningful.
|
|
132
|
+
*/
|
|
133
|
+
/**
|
|
134
|
+
* A button's own visible text, but only when it is plainly UI copy.
|
|
135
|
+
*
|
|
136
|
+
* This deliberately narrows a rule that used to be a flat refusal. The comment
|
|
137
|
+
* below `isAuthoredClass` is right that `textContent` is user content in a way
|
|
138
|
+
* `aria-label` is not: on a personalised page it can carry someone's name or
|
|
139
|
+
* email. But refusing it entirely is what left the click report saying
|
|
140
|
+
* "an unnamed button" as its single biggest row, which is the one row nobody can
|
|
141
|
+
* act on. Most buttons on this site say "Book a call" or "Next testimonial", and
|
|
142
|
+
* throwing that away to guard against a case that mostly does not occur here
|
|
143
|
+
* costs more than it protects.
|
|
144
|
+
*
|
|
145
|
+
* So: take the text, and refuse it the moment it looks like it could be about a
|
|
146
|
+
* person rather than about the interface.
|
|
147
|
+
*
|
|
148
|
+
* Rejected outright:
|
|
149
|
+
* - anything with `@` (an email, or a handle)
|
|
150
|
+
* - any run of 5+ digits (a phone number, an order id, a postcode)
|
|
151
|
+
* - anything longer than 40 characters, which is a sentence, not a label
|
|
152
|
+
* - anything with a newline, which means it swallowed nested markup
|
|
153
|
+
*
|
|
154
|
+
* A rejection falls through to the existing chain, so the worst case is exactly
|
|
155
|
+
* today's behaviour. Nothing here can make the label MORE identifying than it
|
|
156
|
+
* already was.
|
|
157
|
+
*/
|
|
158
|
+
function safeButtonText(button: HTMLButtonElement): string | null {
|
|
159
|
+
const raw = (button.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
160
|
+
if (!raw) return null;
|
|
161
|
+
if (raw.length > 40) return null;
|
|
162
|
+
if (raw.includes("@")) return null;
|
|
163
|
+
if (/\d{5,}/.test(raw)) return null;
|
|
164
|
+
// A label made only of punctuation or symbols (an icon-only button rendering a
|
|
165
|
+
// glyph) tells the reader nothing and would group unrelated buttons together.
|
|
166
|
+
if (!/[a-z]{2,}/i.test(raw)) return null;
|
|
167
|
+
return raw;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isAuthoredClass(name: string): boolean {
|
|
171
|
+
// Variants and arbitrary values are always styling: `hover:bg-x`, `w-[3px]`.
|
|
172
|
+
if (/[:[\]/()]/.test(name)) return false;
|
|
173
|
+
// This project's own convention wins outright: `studio-form__continue`.
|
|
174
|
+
if (name.includes("__")) return true;
|
|
175
|
+
const first = name.split("-")[0]!.toLowerCase();
|
|
176
|
+
if (UTILITY_SEGMENTS.has(first)) return false;
|
|
177
|
+
// A bare number or a one-character class is not a name either.
|
|
178
|
+
return name.length > 2 && !/^\d/.test(name);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function labelFor(el: Element): string | null {
|
|
182
|
+
const authored = el.getAttribute("data-analytics");
|
|
183
|
+
if (authored) return authored.slice(0, 120);
|
|
184
|
+
|
|
185
|
+
// `tagName`, not `instanceof HTMLAnchorElement`. Two reasons, both real:
|
|
186
|
+
// `instanceof` throws outright where the global is absent, and it returns
|
|
187
|
+
// FALSE for an element belonging to another realm — which this site has, since
|
|
188
|
+
// the lead-capture step is an iframe.
|
|
189
|
+
const tag = el.tagName ? el.tagName.toUpperCase() : "";
|
|
190
|
+
|
|
191
|
+
if (tag === "A") {
|
|
192
|
+
const href = (el as HTMLAnchorElement).href;
|
|
193
|
+
if (!href) return "link:empty";
|
|
194
|
+
try {
|
|
195
|
+
const url = new URL(href, window.location.href);
|
|
196
|
+
const sameSite = url.host === window.location.host;
|
|
197
|
+
// Query and hash dropped on purpose: a share, unsubscribe or prefilled
|
|
198
|
+
// link can carry an identifier, and keeping it would put that identifier
|
|
199
|
+
// in the analytics store.
|
|
200
|
+
return `${sameSite ? "link" : "outbound"}:${url.host}${url.pathname}`.slice(0, 120);
|
|
201
|
+
} catch {
|
|
202
|
+
return "link:unparseable";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (tag === "BUTTON") {
|
|
207
|
+
const button = el as HTMLButtonElement;
|
|
208
|
+
|
|
209
|
+
// Order matters, and the last resort is deliberately NOT "the first class".
|
|
210
|
+
// That was the original rule and it produced labels like `button:px-2.5`,
|
|
211
|
+
// `button:group` and `button:rounded-xl` — Tailwind utilities, identical
|
|
212
|
+
// across unrelated buttons and changing on every restyle. Useless for
|
|
213
|
+
// analysis and actively misleading, since two different buttons collapse
|
|
214
|
+
// into one row.
|
|
215
|
+
//
|
|
216
|
+
// `aria-label` is authored markup rather than user content, so it is safe in
|
|
217
|
+
// a way `textContent` is not: an accessible name is written by us, while
|
|
218
|
+
// innerText on a personalised page can contain someone's name or email.
|
|
219
|
+
// Capped like everything else.
|
|
220
|
+
const authoredClass = Array.from(button.classList ?? []).find(isAuthoredClass);
|
|
221
|
+
/*
|
|
222
|
+
Visible text sits AFTER the authored identifiers and BEFORE the class
|
|
223
|
+
fallback. After, because an id or aria-label was chosen deliberately as a
|
|
224
|
+
name and should win. Before, because a Tailwind class is never a name, and
|
|
225
|
+
"Book a call" beats `studio-form__continue` for anyone reading a report.
|
|
226
|
+
*/
|
|
227
|
+
const hint =
|
|
228
|
+
button.id ||
|
|
229
|
+
button.getAttribute("aria-label") ||
|
|
230
|
+
button.getAttribute("name") ||
|
|
231
|
+
safeButtonText(button) ||
|
|
232
|
+
authoredClass ||
|
|
233
|
+
"unlabelled";
|
|
234
|
+
return `button:${hint}`.slice(0, 120);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The nearest thing worth attributing a click to. */
|
|
241
|
+
function trackable(target: EventTarget | null): Element | null {
|
|
242
|
+
if (!(target instanceof Element)) return null;
|
|
243
|
+
return target.closest("[data-analytics], a, button");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function EngagementTracking() {
|
|
247
|
+
const pathname = usePathname();
|
|
248
|
+
const fired = useRef<Set<number>>(new Set());
|
|
249
|
+
const ticking = useRef(false);
|
|
250
|
+
/**
|
|
251
|
+
* The exact furthest point reached, reported once when the page is left.
|
|
252
|
+
*
|
|
253
|
+
* This is what makes a *continuous* curve possible: milestones can only ever
|
|
254
|
+
* draw as many points as there are milestones, whereas one exact value per page
|
|
255
|
+
* view supports any percentile — "half your visitors get past 43%" is a
|
|
256
|
+
* question no fixed bucket set can answer.
|
|
257
|
+
*/
|
|
258
|
+
const maxDepth = useRef(0);
|
|
259
|
+
const reported = useRef(false);
|
|
260
|
+
|
|
261
|
+
// Thresholds are per page: a single-page-app navigation is a new page to a
|
|
262
|
+
// visitor, so carrying "already scrolled 90%" across it would hide the fact
|
|
263
|
+
// that they never scrolled the second one.
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
fired.current = new Set();
|
|
266
|
+
maxDepth.current = 0;
|
|
267
|
+
reported.current = false;
|
|
268
|
+
}, [pathname]);
|
|
269
|
+
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
if (typeof window === "undefined") return;
|
|
272
|
+
|
|
273
|
+
const measure = () => {
|
|
274
|
+
ticking.current = false;
|
|
275
|
+
try {
|
|
276
|
+
const doc = document.documentElement;
|
|
277
|
+
const scrollable = doc.scrollHeight - window.innerHeight;
|
|
278
|
+
// A page shorter than the viewport cannot be scrolled, and reporting
|
|
279
|
+
// 100% for it would make every short page look fully read.
|
|
280
|
+
if (scrollable <= 0) return;
|
|
281
|
+
|
|
282
|
+
const pct = ((window.scrollY || doc.scrollTop || 0) / scrollable) * 100;
|
|
283
|
+
if (pct > maxDepth.current) maxDepth.current = Math.min(100, Math.round(pct));
|
|
284
|
+
for (const threshold of crossedThresholds(pct, fired.current)) {
|
|
285
|
+
fired.current.add(threshold);
|
|
286
|
+
trackFunnelEvent("scroll_depth", { value: threshold });
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
/* a tracking bug must not break scrolling */
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// rAF-coalesced: scroll fires per frame on some browsers and per pixel on
|
|
294
|
+
// others, and this must not be the reason a page janks.
|
|
295
|
+
const onScroll = () => {
|
|
296
|
+
if (ticking.current) return;
|
|
297
|
+
ticking.current = true;
|
|
298
|
+
requestAnimationFrame(measure);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const onClick = (event: Event) => {
|
|
302
|
+
try {
|
|
303
|
+
const el = trackable(event.target);
|
|
304
|
+
if (!el) return;
|
|
305
|
+
const label = labelFor(el);
|
|
306
|
+
if (label) trackFunnelEvent("element_click", { label });
|
|
307
|
+
} catch {
|
|
308
|
+
/* never interfere with a click — especially not a CTA */
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Report the exact maximum on the way out.
|
|
314
|
+
*
|
|
315
|
+
* `visibilitychange` → hidden for the same reason as everywhere else in this
|
|
316
|
+
* collector: `unload` does not fire reliably on mobile Safari. Latched, so a
|
|
317
|
+
* visitor who tabs away four times reports one figure rather than four.
|
|
318
|
+
*/
|
|
319
|
+
const reportMax = () => {
|
|
320
|
+
try {
|
|
321
|
+
if (document.visibilityState !== "hidden") return;
|
|
322
|
+
if (reported.current || maxDepth.current <= 0) return;
|
|
323
|
+
reported.current = true;
|
|
324
|
+
trackFunnelEvent("scroll_max", { value: maxDepth.current });
|
|
325
|
+
} catch {
|
|
326
|
+
/* never let this surface */
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
331
|
+
document.addEventListener("visibilitychange", reportMax);
|
|
332
|
+
// Capture phase, so a handler that stops propagation (a modal trigger, a
|
|
333
|
+
// form's own button) does not make the click invisible to us.
|
|
334
|
+
document.addEventListener("click", onClick, { capture: true, passive: true });
|
|
335
|
+
// Measure once on mount: a visitor landing on an anchor is already scrolled.
|
|
336
|
+
measure();
|
|
337
|
+
|
|
338
|
+
return () => {
|
|
339
|
+
// Report on unmount too — a client-side navigation away from the page is a
|
|
340
|
+
// page view ending, and it never fires visibilitychange.
|
|
341
|
+
if (!reported.current && maxDepth.current > 0) {
|
|
342
|
+
reported.current = true;
|
|
343
|
+
try {
|
|
344
|
+
trackFunnelEvent("scroll_max", { value: maxDepth.current });
|
|
345
|
+
} catch {
|
|
346
|
+
/* never let this surface */
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
window.removeEventListener("scroll", onScroll);
|
|
350
|
+
document.removeEventListener("visibilitychange", reportMax);
|
|
351
|
+
document.removeEventListener("click", onClick, { capture: true });
|
|
352
|
+
};
|
|
353
|
+
}, [pathname]);
|
|
354
|
+
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Hands the published experiment list to the collector so every event carries
|
|
360
|
+
* the visitor's A/B arm.
|
|
361
|
+
*
|
|
362
|
+
* Renders nothing. It exists because the arm was never recorded: the server
|
|
363
|
+
* chose the copy and the browser sent the events, and nothing carried the
|
|
364
|
+
* decision across the gap. `funnel_events.variant` therefore held a form's CSS
|
|
365
|
+
* skin name or nothing at all, so Studio's comparison had two arms that were
|
|
366
|
+
* both really "everyone" — a test could run to completion and report "no
|
|
367
|
+
* difference", correctly, forever.
|
|
368
|
+
*
|
|
369
|
+
* Registered during render rather than in an effect on purpose. `page_view`
|
|
370
|
+
* fires from `FunnelAnalytics`'s effect, which runs AFTER a child's render but
|
|
371
|
+
* could run before a sibling's effect depending on tree order, and an unlabelled
|
|
372
|
+
* page view is a session whose arm is unknown for its most important event.
|
|
373
|
+
* Assigning a module-level variable is idempotent and has no cleanup, so there
|
|
374
|
+
* is nothing here for an effect to buy.
|
|
375
|
+
*
|
|
376
|
+
* The list is published content, fixed for the life of a deploy, so this costs
|
|
377
|
+
* one small prop in the RSC payload and no request.
|
|
378
|
+
*/
|
|
379
|
+
export function AbArm({ experiments }: { experiments: RunningExperiment[] }) {
|
|
380
|
+
registerExperiments(experiments);
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Proves at runtime which build a consumer is actually running. */
|
|
2
|
+
export const PACKAGE_VERSION = "1.0.0";
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
resolveVariant,
|
|
6
|
+
bucketFor,
|
|
7
|
+
CONTROL,
|
|
8
|
+
VARIANT,
|
|
9
|
+
VISITOR_COOKIE,
|
|
10
|
+
type RunningExperiment,
|
|
11
|
+
} from "./bucketing";
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
trackFunnelEvent,
|
|
15
|
+
registerExperiments,
|
|
16
|
+
flushFunnelEvents,
|
|
17
|
+
currentSessionId,
|
|
18
|
+
funnelAnalyticsEnabled,
|
|
19
|
+
consentGranted,
|
|
20
|
+
resetFunnelAnalytics,
|
|
21
|
+
type FunnelEventProps,
|
|
22
|
+
} from "./collector";
|
|
23
|
+
|
|
24
|
+
export { useFormAnalytics, type FormAnalytics, type FormAnalyticsOptions } from "./use-form-analytics";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
FunnelAnalytics,
|
|
28
|
+
EngagementTracking,
|
|
29
|
+
AbArm,
|
|
30
|
+
THRESHOLDS,
|
|
31
|
+
crossedThresholds,
|
|
32
|
+
labelFor,
|
|
33
|
+
} from "./components";
|
|
34
|
+
|
|
35
|
+
export { createCollectHandler } from "./collect-handler";
|
|
36
|
+
|
|
37
|
+
export { withVisitorCookie } from "./proxy";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import type { NextRequest } from "next/server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Middleware helper: the first-party visitor cookie that server-side A/B
|
|
6
|
+
* assignment needs.
|
|
7
|
+
*
|
|
8
|
+
* The med-spa slug canonicalisation redirect that wmm-website's own `proxy.ts`
|
|
9
|
+
* also does is NOT part of this — that is wmm-website's own routing rule, not
|
|
10
|
+
* part of the contract a consumer app onboards to.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Same name the analytics collector reads, so both sides agree on one identity. */
|
|
14
|
+
const VISITOR_COOKIE = "wmm_vid";
|
|
15
|
+
/** 400 days is the maximum Chrome honours for a cookie's lifetime. */
|
|
16
|
+
const VISITOR_COOKIE_MAX_AGE = 400 * 24 * 60 * 60;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A uuid without importing crypto's Node API — middleware runs on the edge
|
|
20
|
+
* runtime, where `crypto.randomUUID` is available on the global.
|
|
21
|
+
*/
|
|
22
|
+
function newVisitorId(): string {
|
|
23
|
+
return crypto.randomUUID();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function withVisitorCookie(req: NextRequest): NextResponse {
|
|
27
|
+
const { pathname } = req.nextUrl;
|
|
28
|
+
|
|
29
|
+
// The path, as a request header.
|
|
30
|
+
//
|
|
31
|
+
// Server components cannot read their own pathname, and A/B assignment has to
|
|
32
|
+
// know which page it is on to find the experiment targeting it. Middleware is
|
|
33
|
+
// the only place that knows both the path and the cookies, so it forwards the
|
|
34
|
+
// path and `lib/content.ts` reads it back.
|
|
35
|
+
const requestHeaders = new Headers(req.headers);
|
|
36
|
+
requestHeaders.set("x-wmm-path", pathname);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `?wmm-variant=b` — show variant B to whoever asked, regardless of bucketing.
|
|
40
|
+
*
|
|
41
|
+
* Studio's editor needs this. Its whole A/B workflow is "make a variant, look
|
|
42
|
+
* at it, then launch", and before launch there IS no experiment, so
|
|
43
|
+
* `activeVariant()` has nothing to resolve and correctly answers control. The
|
|
44
|
+
* editor could therefore never see the thing it was editing.
|
|
45
|
+
*
|
|
46
|
+
* Forwarded as a header rather than read from `searchParams` in the page so
|
|
47
|
+
* that `lib/content.ts` keeps ONE place where the arm is decided, and so a
|
|
48
|
+
* page does not have to opt in to make the preview work.
|
|
49
|
+
*
|
|
50
|
+
* Not a secret and not a risk: both arms are published site copy, so the worst
|
|
51
|
+
* anyone can do with this param is read content we are already serving to some
|
|
52
|
+
* fraction of visitors. It does not enter an experiment, and the collector
|
|
53
|
+
* marks a request carrying it as internal, so a preview cannot write events
|
|
54
|
+
* into the arm under test.
|
|
55
|
+
*/
|
|
56
|
+
const previewVariant = req.nextUrl.searchParams.get("wmm-variant");
|
|
57
|
+
if (previewVariant === "b") requestHeaders.set("x-wmm-variant", "b");
|
|
58
|
+
|
|
59
|
+
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The visitor id, as a first-party cookie.
|
|
63
|
+
*
|
|
64
|
+
* A/B assignment has to happen SERVER-side or the visitor sees a flash of the
|
|
65
|
+
* wrong variant — which is both ugly and a measurement artefact, since the
|
|
66
|
+
* flash itself changes behaviour. The analytics collector keeps its visitor id
|
|
67
|
+
* in localStorage, which a server cannot read, so the identity also lives here
|
|
68
|
+
* where it can be. The collector prefers this cookie, so the two never
|
|
69
|
+
* disagree about who someone is.
|
|
70
|
+
*
|
|
71
|
+
* Not httpOnly, on purpose: the collector reads it in the browser. It carries
|
|
72
|
+
* no PII — it is a random id, exactly like the localStorage value it replaces.
|
|
73
|
+
*/
|
|
74
|
+
if (!req.cookies.get(VISITOR_COOKIE)?.value) {
|
|
75
|
+
response.cookies.set({
|
|
76
|
+
name: VISITOR_COOKIE,
|
|
77
|
+
value: newVisitorId(),
|
|
78
|
+
maxAge: VISITOR_COOKIE_MAX_AGE,
|
|
79
|
+
path: "/",
|
|
80
|
+
sameSite: "lax",
|
|
81
|
+
httpOnly: false,
|
|
82
|
+
secure: true,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return response;
|
|
87
|
+
}
|