@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.
@@ -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
+ }