@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,256 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
4
|
+
import { trackFunnelEvent, type FunnelEventProps } from "./collector";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Funnel analytics for the Studio form renderer
|
|
8
|
+
* (wmm-studio `docs/analytics/02-form-instrumentation.md`).
|
|
9
|
+
*
|
|
10
|
+
* All of the timing, visibility and dedupe logic lives here rather than in the
|
|
11
|
+
* renderer, for two reasons: the renderer stays readable, and this is unit
|
|
12
|
+
* testable against a fake clock without mounting a single component.
|
|
13
|
+
*
|
|
14
|
+
* THE RULE THIS OBEYS: analytics must never affect form behaviour. The form is
|
|
15
|
+
* live lead capture on a page with paid traffic behind it, so —
|
|
16
|
+
*
|
|
17
|
+
* - every method swallows its own errors,
|
|
18
|
+
* - nothing here is ever awaited,
|
|
19
|
+
* - nothing here is React state, so no emit can trigger a render, re-run an
|
|
20
|
+
* effect, or become a dependency of anything that gates validation.
|
|
21
|
+
*
|
|
22
|
+
* A dropped event costs a row in a chart. A blocked submit costs a lead.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type FormAnalytics = {
|
|
26
|
+
/**
|
|
27
|
+
* The form became visible (or mounted). Fires `form_view` once.
|
|
28
|
+
*
|
|
29
|
+
* Pass the form's ordered step ids. They are what lets a dashboard show a step
|
|
30
|
+
* nobody has reached yet.
|
|
31
|
+
*
|
|
32
|
+
* Without them, step structure can only be inferred from the events that have
|
|
33
|
+
* actually fired, so a four-step form whose visitors all stop at step 1 looks
|
|
34
|
+
* like a one-step form — and a dashboard reading it will say so. A form knows
|
|
35
|
+
* its own shape at mount; this is the one place it can say it out loud.
|
|
36
|
+
*/
|
|
37
|
+
view(stepIds?: readonly string[]): void;
|
|
38
|
+
/** A step became active. Starts that step's visible-time clock. */
|
|
39
|
+
stepView(stepIndex: number, stepId: string): void;
|
|
40
|
+
/** A step was passed and left forwards. Carries visible time on the step. */
|
|
41
|
+
stepComplete(stepIndex: number, stepId: string): void;
|
|
42
|
+
/** The visitor went backwards. `stepIndex`/`stepId` are the DESTINATION. */
|
|
43
|
+
stepBack(stepIndex: number, stepId: string): void;
|
|
44
|
+
/** First interaction with any field emits `form_start` once, with the field. */
|
|
45
|
+
fieldInteraction(field: string): void;
|
|
46
|
+
/** A validation error was shown to the visitor. */
|
|
47
|
+
fieldError(field: string, errorKind: string): void;
|
|
48
|
+
/** The lead was captured / the form completed. Emits `form_submit` once. */
|
|
49
|
+
submit(): void;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type Emit = (name: string, props: FunnelEventProps) => void;
|
|
53
|
+
|
|
54
|
+
export type FormAnalyticsOptions = {
|
|
55
|
+
/** Stable identity for the form. The published slot key where there is one. */
|
|
56
|
+
formKey: string;
|
|
57
|
+
/**
|
|
58
|
+
* False disables every emit. Used for Studio's builder preview: an editor
|
|
59
|
+
* clicking through a draft must not write events that look like visitor
|
|
60
|
+
* traffic.
|
|
61
|
+
*/
|
|
62
|
+
enabled?: boolean;
|
|
63
|
+
/** Injectable for tests. Defaults to the real collector. */
|
|
64
|
+
emit?: Emit;
|
|
65
|
+
/** Injectable clock, so time-on-step is asserted rather than slept through. */
|
|
66
|
+
now?: () => number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Visible-time accounting for the active step.
|
|
71
|
+
*
|
|
72
|
+
* `msOnStep` measures time the step was actually on screen: the clock pauses
|
|
73
|
+
* while the tab is hidden and resumes on return. Without pausing, someone who
|
|
74
|
+
* leaves a tab open over lunch reports a forty-minute step and drags the median
|
|
75
|
+
* for everyone else — and the median is the number the dashboard shows.
|
|
76
|
+
*/
|
|
77
|
+
type StepClock = {
|
|
78
|
+
stepIndex: number;
|
|
79
|
+
stepId: string;
|
|
80
|
+
/** Visible ms banked from earlier foreground stretches. */
|
|
81
|
+
bankedMs: number;
|
|
82
|
+
/** When the current foreground stretch began; null while hidden. */
|
|
83
|
+
resumedAt: number | null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export function useFormAnalytics({
|
|
87
|
+
formKey,
|
|
88
|
+
enabled = true,
|
|
89
|
+
emit = trackFunnelEvent,
|
|
90
|
+
now = Date.now,
|
|
91
|
+
}: FormAnalyticsOptions): FormAnalytics {
|
|
92
|
+
const clock = useRef<StepClock | null>(null);
|
|
93
|
+
const viewed = useRef(false);
|
|
94
|
+
const started = useRef(false);
|
|
95
|
+
const submitted = useRef(false);
|
|
96
|
+
/** Keys of `(formKey, stepId)` already abandoned, so a hide/show/hide cycle emits one. */
|
|
97
|
+
const abandoned = useRef<Set<string>>(new Set());
|
|
98
|
+
|
|
99
|
+
// Refs so the visibility listener and the returned methods always read current
|
|
100
|
+
// values without being re-bound, and without `enabled` becoming
|
|
101
|
+
// effect dependencies that could re-register the listener mid-form.
|
|
102
|
+
//
|
|
103
|
+
// Synced in an effect rather than during render (writing a ref while rendering
|
|
104
|
+
// is a React violation). This hook's effects are registered before the calling
|
|
105
|
+
// component's, so the sync always runs before the first `stepView` emit — and
|
|
106
|
+
// the ref is seeded with the first render's values anyway, so even the very
|
|
107
|
+
// first read is correct.
|
|
108
|
+
const config = useRef({ formKey, enabled, emit, now });
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
config.current = { formKey, enabled, emit, now };
|
|
111
|
+
}, [formKey, enabled, emit, now]);
|
|
112
|
+
|
|
113
|
+
const api = useMemo<FormAnalytics>(() => {
|
|
114
|
+
/** Every public method funnels through here; nothing escapes. */
|
|
115
|
+
const send = (name: string, props: FunnelEventProps = {}) => {
|
|
116
|
+
const { formKey: key, enabled: on, emit: send_ } = config.current;
|
|
117
|
+
if (!on) return;
|
|
118
|
+
try {
|
|
119
|
+
send_(name, { formKey: key, ...props });
|
|
120
|
+
} catch {
|
|
121
|
+
/* a tracking bug must not break a form */
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const visibleMs = (): number => {
|
|
126
|
+
const c = clock.current;
|
|
127
|
+
if (!c) return 0;
|
|
128
|
+
const live = c.resumedAt === null ? 0 : config.current.now() - c.resumedAt;
|
|
129
|
+
return c.bankedMs + live;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
view(stepIds) {
|
|
134
|
+
if (viewed.current) return;
|
|
135
|
+
viewed.current = true;
|
|
136
|
+
/*
|
|
137
|
+
The step list rides on `label`, which is a plain text column already
|
|
138
|
+
carried by every event. No migration, and nothing else uses `label` on a
|
|
139
|
+
`form_view`. Comma-joined because step ids are slugs by convention and
|
|
140
|
+
cannot contain a comma; a defensive filter keeps that true even if one
|
|
141
|
+
ever does.
|
|
142
|
+
*/
|
|
143
|
+
const declared = (stepIds ?? [])
|
|
144
|
+
.filter((id) => typeof id === "string" && id.length > 0 && !id.includes(","))
|
|
145
|
+
.join(",");
|
|
146
|
+
send("form_view", declared ? { label: declared } : {});
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
stepView(stepIndex, stepId) {
|
|
150
|
+
clock.current = {
|
|
151
|
+
stepIndex,
|
|
152
|
+
stepId,
|
|
153
|
+
bankedMs: 0,
|
|
154
|
+
resumedAt: config.current.now(),
|
|
155
|
+
};
|
|
156
|
+
send("form_step_view", { stepIndex, stepId });
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
stepComplete(stepIndex, stepId) {
|
|
160
|
+
const msOnStep = clock.current?.stepId === stepId ? visibleMs() : undefined;
|
|
161
|
+
send("form_step_complete", {
|
|
162
|
+
stepIndex,
|
|
163
|
+
stepId,
|
|
164
|
+
...(msOnStep === undefined ? {} : { msOnStep }),
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
stepBack(stepIndex, stepId) {
|
|
169
|
+
send("form_step_back", { stepIndex, stepId });
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
fieldInteraction(field) {
|
|
173
|
+
if (started.current) return;
|
|
174
|
+
started.current = true;
|
|
175
|
+
// Carries the visible time BEFORE the first field was touched.
|
|
176
|
+
//
|
|
177
|
+
// Step 1's clock starts when the step becomes visible, which for a form
|
|
178
|
+
// rendered into the page is page load — so its `msOnStep` on completion
|
|
179
|
+
// is "arrival to submit", not "time spent filling the step". That number
|
|
180
|
+
// is real but it is mostly page-reading time, and read as step friction
|
|
181
|
+
// it points at the wrong thing entirely.
|
|
182
|
+
//
|
|
183
|
+
// Emitting the elapsed time here splits it: this is time-to-engage, and
|
|
184
|
+
// `msOnStep` on completion minus this is time actually filling in. Both
|
|
185
|
+
// are useful; conflating them silently is not.
|
|
186
|
+
send("form_start", { field, msOnStep: visibleMs() });
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
fieldError(field, errorKind) {
|
|
190
|
+
send("form_field_error", { field, errorKind });
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
submit() {
|
|
194
|
+
if (submitted.current) return;
|
|
195
|
+
submitted.current = true;
|
|
196
|
+
send("form_submit");
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
// Intentionally empty: the returned object reads everything through refs, so
|
|
200
|
+
// it must be stable for the lifetime of the form. A changing identity here
|
|
201
|
+
// would re-run the caller's effects and re-emit step views.
|
|
202
|
+
}, []);
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Abandon detection, and the pause half of the step clock.
|
|
206
|
+
*
|
|
207
|
+
* `visibilitychange` → hidden, NOT `unload`: unload does not fire reliably on
|
|
208
|
+
* mobile Safari, which is exactly where abandons happen. The collector flushes
|
|
209
|
+
* on the same event via `sendBeacon`, so the abandon queued here still leaves
|
|
210
|
+
* the page.
|
|
211
|
+
*/
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (typeof document === "undefined") return;
|
|
214
|
+
|
|
215
|
+
const onVisibilityChange = () => {
|
|
216
|
+
try {
|
|
217
|
+
const c = clock.current;
|
|
218
|
+
if (document.visibilityState === "hidden") {
|
|
219
|
+
if (!c) return;
|
|
220
|
+
// Bank the foreground stretch first, so the abandon reports the dwell
|
|
221
|
+
// time up to the moment the visitor left.
|
|
222
|
+
if (c.resumedAt !== null) {
|
|
223
|
+
c.bankedMs += config.current.now() - c.resumedAt;
|
|
224
|
+
c.resumedAt = null;
|
|
225
|
+
}
|
|
226
|
+
if (submitted.current) return;
|
|
227
|
+
|
|
228
|
+
const { formKey: key, enabled: on, emit: send_ } = config.current;
|
|
229
|
+
if (!on) return;
|
|
230
|
+
const dedupeKey = `${key}|${c.stepId}`;
|
|
231
|
+
if (abandoned.current.has(dedupeKey)) return;
|
|
232
|
+
abandoned.current.add(dedupeKey);
|
|
233
|
+
|
|
234
|
+
send_("form_abandon", {
|
|
235
|
+
formKey: key,
|
|
236
|
+
stepIndex: c.stepIndex,
|
|
237
|
+
stepId: c.stepId,
|
|
238
|
+
msOnStep: c.bankedMs,
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Back on screen: resume the clock. `resumedAt` is only null while
|
|
244
|
+
// hidden, so this cannot double-count a stretch.
|
|
245
|
+
if (c && c.resumedAt === null) c.resumedAt = config.current.now();
|
|
246
|
+
} catch {
|
|
247
|
+
/* never let analytics surface on the page */
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
252
|
+
return () => document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
253
|
+
}, []);
|
|
254
|
+
|
|
255
|
+
return api;
|
|
256
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { currentSessionId } from "../analytics/collector";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The CRM custom field that makes a lead traceable to the page that produced it.
|
|
5
|
+
*
|
|
6
|
+
* The name is a cross-system contract: Studio's CRM webhook reads it off the
|
|
7
|
+
* contact and stores it as `crm_events.session_id`, which is what the revenue
|
|
8
|
+
* join keys on. Rename it and every lead silently stops being attributable —
|
|
9
|
+
* nothing errors, the contact is still created, and the funnel just shows no
|
|
10
|
+
* leads.
|
|
11
|
+
*
|
|
12
|
+
* Null rather than an empty string when there is no session: a contact carrying
|
|
13
|
+
* an empty field looks joined and is not.
|
|
14
|
+
*/
|
|
15
|
+
export const SESSION_ID_FIELD = "WMM Session ID";
|
|
16
|
+
|
|
17
|
+
export function sessionIdField(): Record<string, string> | null {
|
|
18
|
+
const sessionId = currentSessionId();
|
|
19
|
+
return sessionId ? { [SESSION_ID_FIELD]: sessionId } : null;
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Proves at runtime which build a consumer is actually running. */
|
|
2
|
+
export const PACKAGE_VERSION = "1.0.0";
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
getAttribution,
|
|
6
|
+
captureAttribution,
|
|
7
|
+
getAttributionPayload,
|
|
8
|
+
computeAttribution,
|
|
9
|
+
flattenAttribution,
|
|
10
|
+
hasCampaignSignal,
|
|
11
|
+
} from "./store";
|
|
12
|
+
export type { Attribution, Touch, AttributionPayload } from "./store";
|
|
13
|
+
|
|
14
|
+
export { sessionIdField, SESSION_ID_FIELD } from "./crm";
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side ad-attribution capture.
|
|
3
|
+
*
|
|
4
|
+
* Ad clicks land on the site carrying `utm_*`, `fbclid`, `gclid` (and Meta's
|
|
5
|
+
* `_fbc`/`_fbp` cookies). Because our forms create the GHL contact server-side
|
|
6
|
+
* via API — bypassing GHL's own tracking script — none of that reaches the
|
|
7
|
+
* lead unless we carry it ourselves. This module captures it on first load,
|
|
8
|
+
* persists first-touch + last-touch, and hands a flat payload to every form so
|
|
9
|
+
* the API routes can forward it into GHL.
|
|
10
|
+
*
|
|
11
|
+
* Client-only (reads window/document/cookies) but window-guarded so it can be
|
|
12
|
+
* imported anywhere without breaking SSR.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { currentSessionId } from "../analytics/collector";
|
|
16
|
+
|
|
17
|
+
const STORAGE_KEY = "wmm_attribution";
|
|
18
|
+
const FIRST_TOUCH_MAX_AGE_DAYS = 90;
|
|
19
|
+
|
|
20
|
+
// utm_id = the ad-platform campaign id (Meta/GA4 "manual campaign id"), passed
|
|
21
|
+
// via dynamic URL params like {{campaign.id}} — kept for campaign-level reporting.
|
|
22
|
+
const UTM_KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "utm_id"] as const;
|
|
23
|
+
// gbraid/wbraid are Google's privacy-era click ids (iOS / app→web) sent instead
|
|
24
|
+
// of gclid when gclid isn't available — needed for Google Ads iOS conversions.
|
|
25
|
+
const CLICK_ID_KEYS = ["gclid", "gbraid", "wbraid", "fbclid", "msclkid"] as const;
|
|
26
|
+
|
|
27
|
+
export type Touch = Partial<Record<(typeof UTM_KEYS)[number] | (typeof CLICK_ID_KEYS)[number], string>> & {
|
|
28
|
+
referrer?: string;
|
|
29
|
+
landing_page?: string;
|
|
30
|
+
timestamp?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type Attribution = {
|
|
34
|
+
first: Touch;
|
|
35
|
+
last: Touch;
|
|
36
|
+
fbc?: string;
|
|
37
|
+
fbp?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Flat shape merged into a form submission and consumed by the server mapper. */
|
|
41
|
+
export type AttributionPayload = Touch & {
|
|
42
|
+
fbc?: string;
|
|
43
|
+
fbp?: string;
|
|
44
|
+
/**
|
|
45
|
+
* The analytics session that produced this submit. Not ad attribution — it
|
|
46
|
+
* rides this payload because this payload is the one channel that already
|
|
47
|
+
* reaches every lead route, so the alternative is the same three lines
|
|
48
|
+
* repeated in seven routes and forgotten in the eighth.
|
|
49
|
+
*/
|
|
50
|
+
wmm_session_id?: string;
|
|
51
|
+
first_utm_source?: string;
|
|
52
|
+
first_utm_medium?: string;
|
|
53
|
+
first_utm_campaign?: string;
|
|
54
|
+
first_landing_page?: string;
|
|
55
|
+
first_referrer?: string;
|
|
56
|
+
first_timestamp?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function isBrowser(): boolean {
|
|
60
|
+
return typeof window !== "undefined";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readCookie(name: string): string | undefined {
|
|
64
|
+
if (!isBrowser()) return undefined;
|
|
65
|
+
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
|
66
|
+
return match ? decodeURIComponent(match[1]!) : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Only external referrers are attribution-worthy — internal navigation isn't. */
|
|
70
|
+
function externalReferrer(): string | undefined {
|
|
71
|
+
if (!isBrowser()) return undefined;
|
|
72
|
+
const ref = document.referrer;
|
|
73
|
+
if (!ref) return undefined;
|
|
74
|
+
try {
|
|
75
|
+
if (new URL(ref).host === window.location.host) return undefined;
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return ref;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readCurrentTouch(): Touch {
|
|
83
|
+
if (!isBrowser()) return {};
|
|
84
|
+
const params = new URLSearchParams(window.location.search);
|
|
85
|
+
const touch: Touch = {};
|
|
86
|
+
for (const key of [...UTM_KEYS, ...CLICK_ID_KEYS]) {
|
|
87
|
+
const value = params.get(key);
|
|
88
|
+
if (value) touch[key] = value;
|
|
89
|
+
}
|
|
90
|
+
const ref = externalReferrer();
|
|
91
|
+
if (ref) touch.referrer = ref;
|
|
92
|
+
touch.landing_page = window.location.origin + window.location.pathname;
|
|
93
|
+
touch.timestamp = new Date().toISOString();
|
|
94
|
+
return touch;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A touch is "campaign-bearing" if it has any UTM or click id — not just a bare referrer/landing. */
|
|
98
|
+
export function hasCampaignSignal(touch: Touch): boolean {
|
|
99
|
+
return [...UTM_KEYS, ...CLICK_ID_KEYS].some((k) => Boolean(touch[k]));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pure first/last-touch reducer — no browser I/O, so it's directly testable.
|
|
104
|
+
* First touch is written once and preserved; last touch advances only when the
|
|
105
|
+
* new visit carries a campaign signal (otherwise the prior last touch stands).
|
|
106
|
+
*/
|
|
107
|
+
export function computeAttribution(
|
|
108
|
+
stored: Attribution | null,
|
|
109
|
+
current: Touch,
|
|
110
|
+
cookies: { fbc?: string; fbp?: string } = {},
|
|
111
|
+
): Attribution {
|
|
112
|
+
const currentHasSignal = hasCampaignSignal(current);
|
|
113
|
+
const storedFirstHasData = Boolean(stored?.first && Object.keys(stored.first).length > 0);
|
|
114
|
+
|
|
115
|
+
const first = storedFirstHasData
|
|
116
|
+
? stored!.first
|
|
117
|
+
: currentHasSignal || current.referrer
|
|
118
|
+
? current
|
|
119
|
+
: {};
|
|
120
|
+
|
|
121
|
+
const last = currentHasSignal ? current : stored?.last ?? first;
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
first,
|
|
125
|
+
last,
|
|
126
|
+
fbc: cookies.fbc ?? stored?.fbc,
|
|
127
|
+
fbp: cookies.fbp ?? stored?.fbp,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Pure flatten — turns stored attribution into the flat submission payload. */
|
|
132
|
+
export function flattenAttribution(attribution: Attribution): AttributionPayload {
|
|
133
|
+
const { first, last, fbc, fbp } = attribution;
|
|
134
|
+
const payload: AttributionPayload = {
|
|
135
|
+
...last,
|
|
136
|
+
...(fbc ? { fbc } : {}),
|
|
137
|
+
...(fbp ? { fbp } : {}),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (first.utm_source) payload.first_utm_source = first.utm_source;
|
|
141
|
+
if (first.utm_medium) payload.first_utm_medium = first.utm_medium;
|
|
142
|
+
if (first.utm_campaign) payload.first_utm_campaign = first.utm_campaign;
|
|
143
|
+
if (first.landing_page) payload.first_landing_page = first.landing_page;
|
|
144
|
+
if (first.referrer) payload.first_referrer = first.referrer;
|
|
145
|
+
if (first.timestamp) payload.first_timestamp = first.timestamp;
|
|
146
|
+
|
|
147
|
+
return payload;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function loadStored(): Attribution | null {
|
|
151
|
+
if (!isBrowser()) return null;
|
|
152
|
+
try {
|
|
153
|
+
const raw = window.localStorage.getItem(STORAGE_KEY) ?? readCookie(STORAGE_KEY);
|
|
154
|
+
if (!raw) return null;
|
|
155
|
+
const parsed = JSON.parse(raw) as Attribution;
|
|
156
|
+
if (parsed && typeof parsed === "object" && parsed.first && parsed.last) return parsed;
|
|
157
|
+
} catch {
|
|
158
|
+
/* corrupt or unavailable — treat as no prior attribution */
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function persist(attribution: Attribution): void {
|
|
164
|
+
if (!isBrowser()) return;
|
|
165
|
+
const raw = JSON.stringify(attribution);
|
|
166
|
+
try {
|
|
167
|
+
window.localStorage.setItem(STORAGE_KEY, raw);
|
|
168
|
+
} catch {
|
|
169
|
+
/* localStorage may be blocked; cookie below is the fallback */
|
|
170
|
+
}
|
|
171
|
+
const maxAge = FIRST_TOUCH_MAX_AGE_DAYS * 24 * 60 * 60;
|
|
172
|
+
document.cookie = `${STORAGE_KEY}=${encodeURIComponent(raw)}; path=/; max-age=${maxAge}; SameSite=Lax`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Meta's click cookie. If `_fbc` isn't set yet but an `fbclid` is on the URL,
|
|
177
|
+
* synthesize it in Meta's documented format so CAPI can match the click.
|
|
178
|
+
*/
|
|
179
|
+
function resolveFbc(currentTouch: Touch): string | undefined {
|
|
180
|
+
const cookie = readCookie("_fbc");
|
|
181
|
+
if (cookie) return cookie;
|
|
182
|
+
const fbclid = currentTouch.fbclid;
|
|
183
|
+
if (fbclid) return `fb.1.${Date.now()}.${fbclid}`;
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Capture attribution from the current URL and merge into stored first/last
|
|
189
|
+
* touch. Idempotent and safe to call on every page mount. Returns the merged
|
|
190
|
+
* attribution (or null on the server).
|
|
191
|
+
*/
|
|
192
|
+
export function captureAttribution(): Attribution | null {
|
|
193
|
+
if (!isBrowser()) return null;
|
|
194
|
+
|
|
195
|
+
const current = readCurrentTouch();
|
|
196
|
+
const stored = loadStored();
|
|
197
|
+
const next = computeAttribution(stored, current, {
|
|
198
|
+
fbc: resolveFbc(current),
|
|
199
|
+
fbp: readCookie("_fbp"),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
persist(next);
|
|
203
|
+
return next;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Read stored attribution without re-capturing. */
|
|
207
|
+
export function getAttribution(): Attribution | null {
|
|
208
|
+
return loadStored();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Flatten stored attribution into the payload merged into form submissions. */
|
|
212
|
+
export function getAttributionPayload(): AttributionPayload {
|
|
213
|
+
const attribution = loadStored() ?? captureAttribution();
|
|
214
|
+
const payload = attribution ? flattenAttribution(attribution) : {};
|
|
215
|
+
// Read, never minted, and never allowed to throw: a lead must be submittable
|
|
216
|
+
// with no analytics session at all. See docs/analytics/03 §3 — "a lost
|
|
217
|
+
// attribution row is acceptable; a lost lead is not."
|
|
218
|
+
try {
|
|
219
|
+
const sessionId = currentSessionId();
|
|
220
|
+
if (sessionId) payload.wmm_session_id = sessionId;
|
|
221
|
+
} catch {
|
|
222
|
+
/* no session available — submit without it */
|
|
223
|
+
}
|
|
224
|
+
return payload;
|
|
225
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merging a Studio slot override into a dictionary.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from lib/content.ts so the Studio slot-preview bridge can run the
|
|
5
|
+
* SAME merge in the browser that the server runs during a real render. That
|
|
6
|
+
* sharing is the point, not a convenience: a preview built on a second
|
|
7
|
+
* implementation of this logic would drift from production and quietly start
|
|
8
|
+
* lying about what a publish will look like — which is the one thing a preview
|
|
9
|
+
* exists not to do.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately free of `server-only` and of any import that pulls it in.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Resolve a `{ en, es }` localized value to the locale string; pass anything
|
|
15
|
+
* else through unchanged. */
|
|
16
|
+
export function resolveLocalized(v: unknown, isEs: boolean): unknown {
|
|
17
|
+
// An ARRAY of localized pairs — a list item's `localizedTextList` field, e.g. a
|
|
18
|
+
// med-spa system step's `tags` — resolves element by element. Needed because an
|
|
19
|
+
// array is not itself a localized pair, so without this branch it falls through
|
|
20
|
+
// untouched and the page receives `[{ en, es }, …]` where it renders strings.
|
|
21
|
+
if (Array.isArray(v)) return v.map((el) => resolveLocalized(el, isEs));
|
|
22
|
+
if (v && typeof v === "object" && ("en" in v || "es" in v)) {
|
|
23
|
+
const o = v as { en?: string; es?: string };
|
|
24
|
+
return (isEs ? o.es : o.en) || o.en;
|
|
25
|
+
}
|
|
26
|
+
return v;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Flatten a list item: resolve every `{ en, es }` field to the locale string,
|
|
30
|
+
* leaving locale-invariant fields (author, company, video, hidden, …) as-is. */
|
|
31
|
+
export function resolveLocalizedFields(item: unknown, isEs: boolean): unknown {
|
|
32
|
+
if (!item || typeof item !== "object") return item;
|
|
33
|
+
const out: Record<string, unknown> = {};
|
|
34
|
+
for (const [k, v] of Object.entries(item as Record<string, unknown>)) {
|
|
35
|
+
out[k] = resolveLocalized(v, isEs);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Set a nested value by dot-path; no-op if an intermediate segment is missing. */
|
|
41
|
+
export function setPath(
|
|
42
|
+
obj: Record<string, unknown>,
|
|
43
|
+
path: string,
|
|
44
|
+
value: unknown,
|
|
45
|
+
): void {
|
|
46
|
+
const parts = path.split(".");
|
|
47
|
+
let cursor: Record<string, unknown> = obj;
|
|
48
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
49
|
+
const next = cursor[parts[i]];
|
|
50
|
+
if (next === null || typeof next !== "object") return;
|
|
51
|
+
cursor = next as Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
cursor[parts[parts.length - 1]] = value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The value a slot override contributes at its `dictPath`, or `undefined` when it
|
|
58
|
+
* contributes nothing and the dict should be left alone.
|
|
59
|
+
*
|
|
60
|
+
* `undefined` covers three distinct "leave it" cases that all have to behave the
|
|
61
|
+
* same way: the slot isn't overridden, a `list` override whose `items` isn't an
|
|
62
|
+
* array (malformed — better to render the code copy than an empty section), and a
|
|
63
|
+
* localized pair that resolves to nothing.
|
|
64
|
+
*/
|
|
65
|
+
export function overrideValueForDict(
|
|
66
|
+
type: string,
|
|
67
|
+
override: unknown,
|
|
68
|
+
isEs: boolean,
|
|
69
|
+
): unknown {
|
|
70
|
+
if (override === null || override === undefined) return undefined;
|
|
71
|
+
|
|
72
|
+
if (type === "list") {
|
|
73
|
+
const items = (override as { items?: unknown[] }).items;
|
|
74
|
+
if (!Array.isArray(items)) return undefined;
|
|
75
|
+
return items.map((item) => resolveLocalizedFields(item, isEs));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const value = resolveLocalized(override, isEs);
|
|
79
|
+
return value === null ? undefined : value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A copy of `dict` with one slot override applied at `dictPath`.
|
|
84
|
+
*
|
|
85
|
+
* The clone is what makes this safe to call from React state on every keystroke:
|
|
86
|
+
* the caller's dict is never mutated, so re-applying a different draft always
|
|
87
|
+
* starts from the published baseline instead of compounding edits.
|
|
88
|
+
*/
|
|
89
|
+
export function withSlotOverride<T>(
|
|
90
|
+
dict: T,
|
|
91
|
+
slot: { type: string; dictPath: string },
|
|
92
|
+
override: unknown,
|
|
93
|
+
isEs: boolean,
|
|
94
|
+
): T {
|
|
95
|
+
const value = overrideValueForDict(slot.type, override, isEs);
|
|
96
|
+
if (value === undefined) return dict;
|
|
97
|
+
|
|
98
|
+
const clone = structuredClone(dict);
|
|
99
|
+
setPath(clone as Record<string, unknown>, slot.dictPath, value);
|
|
100
|
+
return clone;
|
|
101
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Hand-maintained alongside headers.mjs. Nothing type-checks the .mjs against
|
|
2
|
+
// this file, so keep the signature exact when the implementation changes.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns the `Content-Security-Policy: frame-ancestors` header a Studio-edited
|
|
6
|
+
* app must emit (and the `X-Frame-Options` header it must NOT emit alongside).
|
|
7
|
+
* Throws on a wildcard origin or an origin with a path/search/hash.
|
|
8
|
+
*/
|
|
9
|
+
export declare function studioFrameAncestors(
|
|
10
|
+
studioOrigins: readonly string[],
|
|
11
|
+
): { key: string; value: string };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framing rule for an app Studio edits.
|
|
3
|
+
*
|
|
4
|
+
* Studio's click-to-edit frames the REAL page, not a preview route, so a bare
|
|
5
|
+
* `frame-ancestors 'self'` turns the editor into a grey box — which shipped once,
|
|
6
|
+
* with the site still serving, the build still passing, and the only symptom in
|
|
7
|
+
* another repository's UI.
|
|
8
|
+
*
|
|
9
|
+
* One header, never two. `X-Frame-Options` has no allowlist, so it cannot express
|
|
10
|
+
* "self plus Studio" and re-breaks the editor wherever it is still honoured.
|
|
11
|
+
*
|
|
12
|
+
* Authored as plain ESM (not compiled from TypeScript) so `next.config.mjs`,
|
|
13
|
+
* which Next loads through native `import()`, can reach it directly. Types
|
|
14
|
+
* live alongside in `headers.d.ts`, hand-maintained.
|
|
15
|
+
*
|
|
16
|
+
* @param {readonly string[]} studioOrigins
|
|
17
|
+
* @returns {{ key: string, value: string }}
|
|
18
|
+
*/
|
|
19
|
+
export function studioFrameAncestors(studioOrigins) {
|
|
20
|
+
for (const origin of studioOrigins) {
|
|
21
|
+
if (origin.includes("*")) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Wildcard origin "${origin}" would allow everyone: any host matching it can be claimed.`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = new URL(origin);
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error(`"${origin}" is not an origin.`);
|
|
31
|
+
}
|
|
32
|
+
if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
33
|
+
throw new Error(`"${origin}" has a path; frame-ancestors matches origins only.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
key: "Content-Security-Policy",
|
|
38
|
+
value: ["frame-ancestors 'self'", ...studioOrigins].join(" "),
|
|
39
|
+
};
|
|
40
|
+
}
|