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