@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,550 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useMemo,
6
+ useState,
7
+ type ComponentType,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { withSlotOverride } from "./dict-overrides";
11
+
12
+ /**
13
+ * Click-to-edit overlay (Studio Phase 5, extended for Preview-first editing
14
+ * Phase B). Renders nothing for normal visitors.
15
+ *
16
+ * When the page is loaded with `?wmm-edit=1` AND inside a frame (i.e. Studio's
17
+ * preview panel), it:
18
+ * - highlights any element carrying a `data-wmm-slot` on hover and, on
19
+ * click, postMessages that slot key up to Studio so it can open the
20
+ * slot's editor;
21
+ * - posts a `slot-inventory` of every `data-wmm-slot` key present on the
22
+ * page alongside `ready`, so Studio knows which rows it can locate;
23
+ * - listens for `slot-highlight` / `slot-highlight-clear` messages FROM
24
+ * Studio and outlines + scrolls to the matching element with a small
25
+ * label chip (single-highlight semantics — a new highlight replaces the
26
+ * previous one).
27
+ * It never writes anything itself — Studio does, with auth. Gated on both the
28
+ * query flag and being framed, so it can never affect a real visitor. Every
29
+ * inbound message is origin-checked against `studioOrigin`; listeners are only
30
+ * registered after the guard passes and are always cleaned up on unmount.
31
+ */
32
+ const DEFAULT_STUDIO_ORIGIN = "https://wmm-studio.vercel.app";
33
+
34
+ const HIGHLIGHT_STYLE_ID = "wmm-edit-highlight-style";
35
+ const CHIP_ID = "wmm-edit-highlight-chip";
36
+ const HIGHLIGHT_ATTR = "data-wmm-highlighted";
37
+
38
+ type InboundMessage =
39
+ | { source: "wmm-edit"; type: "slot-highlight"; slot: string; label?: string }
40
+ | { source: "wmm-edit"; type: "slot-highlight-clear" };
41
+
42
+ function isInboundMessage(data: unknown): data is InboundMessage {
43
+ if (typeof data !== "object" || data === null) return false;
44
+ const d = data as Record<string, unknown>;
45
+ if (d.source !== "wmm-edit") return false;
46
+ if (d.type === "slot-highlight") return typeof d.slot === "string";
47
+ if (d.type === "slot-highlight-clear") return true;
48
+ return false;
49
+ }
50
+
51
+ export function WmmEditOverlay({
52
+ studioOrigin = DEFAULT_STUDIO_ORIGIN,
53
+ }: {
54
+ /** Defaults to `NEXT_PUBLIC_STUDIO_ORIGIN` in wmm-website; here the app passes
55
+ * its own value explicitly since this package reads no env directly. */
56
+ studioOrigin?: string;
57
+ } = {}) {
58
+ useEffect(() => {
59
+ if (typeof window === "undefined") return;
60
+ const params = new URLSearchParams(window.location.search);
61
+ if (params.get("wmm-edit") !== "1") return;
62
+ if (window.self === window.top) return; // only inside Studio's frame
63
+
64
+ const STYLE_ID = "wmm-edit-style";
65
+ if (!document.getElementById(STYLE_ID)) {
66
+ const style = document.createElement("style");
67
+ style.id = STYLE_ID;
68
+ style.textContent = `
69
+ [data-wmm-slot]{cursor:pointer !important;}
70
+ [data-wmm-slot]:hover{outline:2px solid #8fccb6 !important;outline-offset:2px;border-radius:2px;}
71
+ `;
72
+ document.head.appendChild(style);
73
+ }
74
+ if (!document.getElementById(HIGHLIGHT_STYLE_ID)) {
75
+ const style = document.createElement("style");
76
+ style.id = HIGHLIGHT_STYLE_ID;
77
+ // Periwinkle — deliberately distinct from the aqua hover above, so an
78
+ // editor can tell "Studio pointed here" apart from "I'm hovering this".
79
+ style.textContent = `
80
+ [${HIGHLIGHT_ATTR}]{outline:2px solid #869ce2 !important;outline-offset:2px;border-radius:2px;}
81
+ #${CHIP_ID}{position:absolute;z-index:2147483647;pointer-events:none;
82
+ background:#1f2233;color:#fff;font:600 12px/1.4 system-ui,sans-serif;
83
+ padding:3px 8px;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,.25);
84
+ transform:translateY(-100%);white-space:nowrap;}
85
+ `;
86
+ document.head.appendChild(style);
87
+ }
88
+
89
+ const onClick = (e: MouseEvent) => {
90
+ const el = (e.target as Element | null)?.closest("[data-wmm-slot]");
91
+ if (!el) return;
92
+ // Intercept before the site's own handlers (e.g. a CTA link navigating).
93
+ e.preventDefault();
94
+ e.stopPropagation();
95
+ const slot = el.getAttribute("data-wmm-slot");
96
+ if (slot) {
97
+ window.parent.postMessage(
98
+ { source: "wmm-edit", type: "slot-click", slot },
99
+ studioOrigin,
100
+ );
101
+ }
102
+ };
103
+
104
+ const clearHighlight = () => {
105
+ document
106
+ .querySelectorAll(`[${HIGHLIGHT_ATTR}]`)
107
+ .forEach((el) => el.removeAttribute(HIGHLIGHT_ATTR));
108
+ document.getElementById(CHIP_ID)?.remove();
109
+ };
110
+
111
+ const applyHighlight = (slot: string, label?: string) => {
112
+ clearHighlight();
113
+ const el = document.querySelector(`[data-wmm-slot="${CSS.escape(slot)}"]`);
114
+ if (!el) return; // untagged / not-on-this-page slot — no-op, no noise
115
+ el.setAttribute(HIGHLIGHT_ATTR, "1");
116
+ const reduceMotion = window.matchMedia(
117
+ "(prefers-reduced-motion: reduce)",
118
+ ).matches;
119
+ /*
120
+ Deliberately NOT `el.scrollIntoView()`. That scrolls every scrollable
121
+ ancestor of the element, and the ancestor chain crosses the frame
122
+ boundary — so it also scrolled the Studio page framing us. Hovering a
123
+ row in Studio's slot list yanked the whole console up and down.
124
+
125
+ `window.scrollTo` acts on this document's viewport only, so the preview
126
+ scrolls and nothing outside the iframe moves. Centring is done by hand
127
+ since we no longer get `block: "center"` for free.
128
+ */
129
+ const elRect = el.getBoundingClientRect();
130
+ const centred =
131
+ elRect.top + window.scrollY - (window.innerHeight - elRect.height) / 2;
132
+ window.scrollTo({
133
+ top: Math.max(0, centred),
134
+ behavior: reduceMotion ? "auto" : "smooth",
135
+ });
136
+ if (label) {
137
+ const chip = document.createElement("div");
138
+ chip.id = CHIP_ID;
139
+ chip.textContent = label;
140
+ document.body.appendChild(chip);
141
+ const rect = el.getBoundingClientRect();
142
+ chip.style.left = `${Math.max(0, rect.left + window.scrollX)}px`;
143
+ chip.style.top = `${rect.top + window.scrollY - 6}px`;
144
+ }
145
+ };
146
+
147
+ const onMessage = (e: MessageEvent) => {
148
+ if (e.origin !== studioOrigin) return;
149
+ if (!isInboundMessage(e.data)) return;
150
+ if (e.data.type === "slot-highlight") {
151
+ applyHighlight(e.data.slot, e.data.label);
152
+ } else {
153
+ clearHighlight();
154
+ }
155
+ };
156
+
157
+ document.addEventListener("click", onClick, true);
158
+ window.addEventListener("message", onMessage);
159
+
160
+ const slots = Array.from(
161
+ document.querySelectorAll("[data-wmm-slot]"),
162
+ )
163
+ .map((el) => el.getAttribute("data-wmm-slot"))
164
+ .filter((s): s is string => Boolean(s));
165
+
166
+ window.parent.postMessage({ source: "wmm-edit", type: "ready" }, studioOrigin);
167
+ window.parent.postMessage(
168
+ { source: "wmm-edit", type: "slot-inventory", slots },
169
+ studioOrigin,
170
+ );
171
+
172
+ return () => {
173
+ document.removeEventListener("click", onClick, true);
174
+ window.removeEventListener("message", onMessage);
175
+ clearHighlight();
176
+ document.getElementById(STYLE_ID)?.remove();
177
+ document.getElementById(HIGHLIGHT_STYLE_ID)?.remove();
178
+ };
179
+ }, [studioOrigin]);
180
+
181
+ return null;
182
+ }
183
+
184
+ const SLOT_CHANNEL = "wmm-studio-form-preview";
185
+
186
+ /** How long to wait before saying nothing is coming, matching the form bridge. */
187
+ const HANDSHAKE_TIMEOUT_MS = 4000;
188
+
189
+ type IncomingMessage = {
190
+ source?: string;
191
+ type?: string;
192
+ config?: unknown;
193
+ locale?: unknown;
194
+ theme?: unknown;
195
+ };
196
+
197
+ /**
198
+ * The slot being previewed, as the server resolved it from the manifest.
199
+ *
200
+ * `dictPath` is what makes a slot previewable at all: the layout's override merge
201
+ * is dict-based, so a slot with no dictPath is read via `getSlot` straight into a
202
+ * server prop and cannot be swapped client-side. The page refuses those rather
203
+ * than framing a page that silently ignores every edit.
204
+ */
205
+ export type PreviewableSlot = { key: string; type: string; dictPath: string };
206
+
207
+ /**
208
+ * Renders the REAL page from a DRAFT slot value that Studio posts in, so an
209
+ * editor sees an unsaved change on the actual page before publishing.
210
+ *
211
+ * The companion to StudioFormPreviewBridge, and deliberately the same protocol,
212
+ * same channel and same origin rule — Studio's `FormPreviewFrame` drives both, so
213
+ * a second dialect would be one more thing to keep in step:
214
+ *
215
+ * consumer → studio { source, type: "ready" }
216
+ * studio → consumer { source, type: "config", config, locale?, theme? }
217
+ *
218
+ * What arrives in `config` is the slot's value, already run through Studio's
219
+ * save-flow validator on the sending side. It is applied to a CLONE of the
220
+ * published dict via the same merge the server uses (`withSlotOverride` in
221
+ * `content/dict-overrides.ts`), so the render is the published page with exactly
222
+ * one value swapped — not an approximation, and not a compounding pile of edits.
223
+ *
224
+ * Nothing is written anywhere. The draft lives in component state for the life of
225
+ * this frame and is never sent to Studio, to Supabase, or to GHL.
226
+ *
227
+ * `DictProvider` is the one piece this package cannot own: every consuming app
228
+ * has its own dictionary shape and its own `useDict()` that the rest of the page
229
+ * tree already reads from. This bridge must render THAT SAME provider — a second
230
+ * implementation of dict context would leave `children`'s `useDict()` calls
231
+ * unable to see the draft — so the app passes its provider in rather than this
232
+ * module importing one.
233
+ */
234
+ export function StudioSlotPreviewBridge<TDict, TLang extends string = string>({
235
+ studioOrigin,
236
+ slot,
237
+ dict,
238
+ lang,
239
+ isEs,
240
+ DictProvider,
241
+ children,
242
+ }: {
243
+ /**
244
+ * The single origin this bridge will talk to — already resolved server-side by
245
+ * the page (the app's studio content URL, or a `?studioOrigin=` claim that
246
+ * passed the app's own allow-list). Resolved there on purpose: the page already
247
+ * has searchParams, so the client needs no window access and there is nothing
248
+ * for SSR and hydration to disagree about.
249
+ */
250
+ studioOrigin: string | null;
251
+ slot: PreviewableSlot;
252
+ /** The published dict — overrides already applied by the layout. */
253
+ dict: TDict;
254
+ lang: TLang;
255
+ /** Whether to resolve `{ en, es }` pairs to Spanish, from the route's locale. */
256
+ isEs: boolean;
257
+ /** The app's own dict-context provider (see doc comment above). */
258
+ DictProvider: (props: { dict: TDict; lang: TLang; children: ReactNode }) => ReactNode;
259
+ children: ReactNode;
260
+ }) {
261
+ const [draft, setDraft] = useState<unknown>(undefined);
262
+ const [timedOut, setTimedOut] = useState(false);
263
+
264
+ useEffect(() => {
265
+ const origin = studioOrigin;
266
+ if (!origin) return; // without a known origin we accept nothing
267
+
268
+ function onMessage(e: MessageEvent) {
269
+ if (e.origin !== origin) return;
270
+ const data = e.data as IncomingMessage | undefined;
271
+ if (data?.source !== SLOT_CHANNEL || data.type !== "config") return;
272
+ setDraft(data.config);
273
+ }
274
+
275
+ window.addEventListener("message", onMessage);
276
+ // Announce readiness so Studio posts the current draft immediately instead of
277
+ // waiting for the next keystroke.
278
+ if (window.parent !== window) {
279
+ window.parent.postMessage({ source: SLOT_CHANNEL, type: "ready" }, origin);
280
+ }
281
+ const t = setTimeout(() => setTimedOut(true), HANDSHAKE_TIMEOUT_MS);
282
+ return () => {
283
+ window.removeEventListener("message", onMessage);
284
+ clearTimeout(t);
285
+ };
286
+ }, [studioOrigin]);
287
+
288
+ /**
289
+ * Re-derived from the PUBLISHED dict on every draft, never from the previous
290
+ * preview, so edits replace rather than accumulate. `withSlotOverride` returns
291
+ * the input unchanged when the draft contributes nothing, which keeps the
292
+ * published render on screen instead of blanking the frame.
293
+ */
294
+ const previewDict = useMemo(
295
+ () => (draft === undefined ? dict : withSlotOverride(dict, slot, draft, isEs)),
296
+ [dict, slot, draft, isEs],
297
+ );
298
+
299
+ if (!studioOrigin) {
300
+ return (
301
+ <div className="p-8 text-sm text-slate-400">
302
+ <p>Live preview unavailable — Studio&apos;s origin can&apos;t be verified.</p>
303
+ <p className="mt-2 text-xs">
304
+ This site has no studio content URL configured, and no allowed{" "}
305
+ <code className="font-mono">?studioOrigin=</code> was supplied.
306
+ </p>
307
+ </div>
308
+ );
309
+ }
310
+
311
+ return (
312
+ <>
313
+ {/* The page renders regardless, so an editor sees the real published page
314
+ while the handshake completes rather than a spinner. */}
315
+ <DictProvider dict={previewDict} lang={lang}>
316
+ {children}
317
+ </DictProvider>
318
+ {draft === undefined && timedOut ? (
319
+ <div className="fixed bottom-3 left-3 z-50 max-w-sm rounded-md bg-slate-900/95 px-3 py-2 text-xs text-slate-300 shadow-lg">
320
+ Showing published content — nothing arrived from Studio. This page
321
+ accepts messages only from{" "}
322
+ <code className="font-mono text-slate-100">{studioOrigin}</code>.
323
+ </div>
324
+ ) : null}
325
+ </>
326
+ );
327
+ }
328
+
329
+ const FORM_CHANNEL = "wmm-studio-form-preview";
330
+
331
+ type FormIncomingMessage = {
332
+ source?: string;
333
+ type?: string;
334
+ config?: unknown;
335
+ locale?: unknown;
336
+ theme?: unknown;
337
+ };
338
+
339
+ /**
340
+ * The exact props `StudioFormPreviewBridge` renders the app's form component
341
+ * with. Nothing here is caller-supplied: the bridge builds this object.
342
+ */
343
+ export type StudioPreviewFormProps<TConfig> = {
344
+ /** A draft the app's own `parseConfig` already validated. */
345
+ config: TConfig;
346
+ locale: string;
347
+ theme: "dark" | "light";
348
+ /**
349
+ * ALWAYS `true`, and typed as the literal rather than `boolean` so that the
350
+ * only components this bridge accepts are ones whose `preview` prop can BE
351
+ * `true`. A component declaring `preview: false`, or a `preview` of an
352
+ * unrelated type, fails to typecheck at the call site.
353
+ *
354
+ * A form component whose own prop is the usual `preview?: boolean` (it has to
355
+ * be, so the live site can render it submittable) satisfies this and receives
356
+ * `true` here, every time, from the bridge.
357
+ */
358
+ preview: true;
359
+ };
360
+
361
+ /**
362
+ * What `formComponent` must be: the app's form component, accepting the props
363
+ * above. Deliberately a COMPONENT and not a render callback — see the long note
364
+ * on `StudioFormPreviewBridge` for why the callback shape could not hold the
365
+ * non-submitting guarantee.
366
+ */
367
+ export type StudioPreviewFormComponent<TConfig> = ComponentType<
368
+ StudioPreviewFormProps<TConfig>
369
+ >;
370
+
371
+ /**
372
+ * Renders the app's own form component from a DRAFT config that Studio's form
373
+ * builder posts in, so an editor sees their form exactly as visitors will while
374
+ * they build it. Mirrors the click-to-edit bridge: messages are accepted only
375
+ * from Studio's own origin.
376
+ *
377
+ * Protocol (both ways, origin-checked):
378
+ * consumer → studio { source, type: "ready" } // iframe is listening
379
+ * studio → consumer { source, type: "config", config, locale?, theme? }
380
+ *
381
+ * `parseConfig` and `formComponent` are the two pieces this package cannot own:
382
+ * the form config shape (a zod-validated mirror of Studio's `form_config` value)
383
+ * and the form component itself (GHL submission, lead-frame attribution,
384
+ * conditional fields — none of it part of the Studio/content contract) both
385
+ * belong to the consuming app, exactly like the manifest does. The app supplies
386
+ * its own validator and its own component; this bridge owns only the handshake,
387
+ * the timeout, and the invalid/waiting/preview states around them.
388
+ *
389
+ * **Everything rendered through this bridge renders in preview / non-submitting
390
+ * mode — no GHL writes, no analytics, no lead-frame — and that is now the type's
391
+ * job, not the caller's.** `lib/studio-preview-origin.ts` in wmm-website justifies
392
+ * an allow-list of framing origins wider than the live site's CSP specifically on
393
+ * that guarantee: the residual risk of framing this bridge from an unexpected
394
+ * origin is "someone renders their own text to themselves", which is only true
395
+ * because nothing submitted here reaches the CRM.
396
+ *
397
+ * That is why this prop is the form COMPONENT and not a `renderForm` closure. A
398
+ * closure was the earlier shape and it could not hold the property: TypeScript
399
+ * lets a closure destructure any subset of a contextually-typed parameter, so
400
+ * `({ config, locale, theme }) => …` compiled clean while silently dropping
401
+ * `preview`, and an app whose own prop is `preview?: boolean` could pass
402
+ * `preview={false}` back after the spread. Both were legal. Handing over the
403
+ * component instead removes the props object from the call site entirely: the
404
+ * bridge constructs the props and renders `preview` itself, so there is nothing
405
+ * left for a caller to forget or to override.
406
+ *
407
+ * What the type does and does not buy you, stated exactly:
408
+ * - There is no `renderForm` prop and no `preview` prop on this bridge, so
409
+ * neither can be passed — both are compile errors.
410
+ * - `formComponent` is typed against props whose `preview` is the literal
411
+ * `true`, so a component that cannot accept `true` there (`preview: false`,
412
+ * or a `preview` of some other type) is a compile error.
413
+ * - It does NOT stop a caller who deliberately wraps: an inline
414
+ * `formComponent={(p) => <MyForm {...p} preview={false} />}` still compiles,
415
+ * because `MyForm`'s own `preview` must stay `boolean` for the live site to
416
+ * use it. No type in this package can forbid that while the same component
417
+ * also has to render live. That is sabotage, not forgetfulness.
418
+ * - It does NOT reject a component that declares no `preview` prop at all
419
+ * (structural typing accepts it, and it would simply ignore ours). Such a
420
+ * component has no preview mode to force; if yours is like that, give it one
421
+ * before mounting this bridge.
422
+ *
423
+ * See `tests/form-preview-enforcement.test.tsx` for the compiled proof of each
424
+ * line above.
425
+ */
426
+ export function StudioFormPreviewBridge<TConfig>({
427
+ studioOrigin,
428
+ fallback = null,
429
+ defaultLocale,
430
+ parseConfig,
431
+ formComponent: FormComponent,
432
+ }: {
433
+ /**
434
+ * The single origin this bridge will talk to — already resolved by the page
435
+ * (the app's studio content URL, or a `?studioOrigin=` claim that passed the
436
+ * app's own allow-list). Resolved server-side on purpose: the page already has
437
+ * searchParams, so the client needs no window access, no extra state, and there
438
+ * is nothing for SSR and hydration to disagree about.
439
+ */
440
+ studioOrigin: string | null;
441
+ /** Used until (or unless) Studio posts a config — e.g. the sample form. */
442
+ fallback?: TConfig | null;
443
+ defaultLocale: string;
444
+ /** Validates a draft config Studio posts in — the app's own form-config schema. */
445
+ parseConfig: (raw: unknown) => { ok: true; config: TConfig } | { ok: false; errors: string[] };
446
+ /**
447
+ * The app's own form component. The bridge renders it — the caller never gets
448
+ * a props object, so `preview` is not theirs to omit or to override.
449
+ *
450
+ * Pass the component itself (`formComponent={MyForm}`), by reference, not an
451
+ * inline arrow: an inline wrapper re-creates the component type on every
452
+ * render, which remounts the form and throws away whatever the editor was
453
+ * looking at, and it is also the one route by which a caller could hand this
454
+ * bridge something submittable (see the note on the function above).
455
+ */
456
+ formComponent: StudioPreviewFormComponent<TConfig>;
457
+ }) {
458
+ const [config, setConfig] = useState<TConfig | null>(fallback);
459
+ const [locale, setLocale] = useState(defaultLocale);
460
+ const [theme, setTheme] = useState<"dark" | "light">("dark");
461
+ // A config that arrived but failed validation, so the panel can say WHY instead
462
+ // of looking identical to no config at all.
463
+ const [invalid, setInvalid] = useState<string[] | null>(null);
464
+ // Set once the handshake has had time to complete, to distinguish "still
465
+ // connecting" from "nothing is ever coming".
466
+ const [timedOut, setTimedOut] = useState(false);
467
+
468
+ useEffect(() => {
469
+ const origin = studioOrigin;
470
+ if (!origin) return; // without a known origin we accept nothing
471
+
472
+ function onMessage(e: MessageEvent) {
473
+ if (e.origin !== origin) return;
474
+ const data = e.data as FormIncomingMessage | undefined;
475
+ if (data?.source !== FORM_CHANNEL || data.type !== "config") return;
476
+
477
+ const parsed = parseConfig(data.config);
478
+ if (parsed.ok) {
479
+ setConfig(parsed.config);
480
+ setInvalid(null);
481
+ } else {
482
+ // Surface it. A draft that doesn't validate is the single most likely
483
+ // reason a builder preview is blank, and silence sent the last
484
+ // investigation looking at postMessage instead.
485
+ setInvalid(parsed.errors);
486
+ }
487
+ if (typeof data.locale === "string" && data.locale) setLocale(data.locale);
488
+ if (data.theme === "light" || data.theme === "dark") setTheme(data.theme);
489
+ }
490
+
491
+ window.addEventListener("message", onMessage);
492
+ // Tell the opener we're ready — it replies with the current draft.
493
+ if (window.parent !== window) {
494
+ window.parent.postMessage({ source: FORM_CHANNEL, type: "ready" }, origin);
495
+ }
496
+ const t = setTimeout(() => setTimedOut(true), 4000);
497
+ return () => {
498
+ window.removeEventListener("message", onMessage);
499
+ clearTimeout(t);
500
+ };
501
+ }, [studioOrigin, parseConfig]);
502
+
503
+ if (!studioOrigin) {
504
+ return (
505
+ <div className="text-sm text-slate-400">
506
+ <p>Live preview unavailable — Studio&apos;s origin can&apos;t be verified.</p>
507
+ <p className="mt-2 text-xs">
508
+ This site has no studio content URL configured, and no allowed{" "}
509
+ <code className="font-mono">?studioOrigin=</code> was supplied.
510
+ </p>
511
+ </div>
512
+ );
513
+ }
514
+
515
+ if (invalid) {
516
+ return (
517
+ <div className="text-sm text-red-400">
518
+ <p>Studio sent a form that doesn&apos;t validate:</p>
519
+ <ul className="mt-2 list-disc pl-5 text-xs">
520
+ {invalid.map((e) => (
521
+ <li key={e}>{e}</li>
522
+ ))}
523
+ </ul>
524
+ </div>
525
+ );
526
+ }
527
+
528
+ if (!config) {
529
+ return (
530
+ <div className="text-sm text-slate-400">
531
+ <p>Waiting for the form from Studio…</p>
532
+ {timedOut ? (
533
+ <p className="mt-2 text-xs">
534
+ Nothing arrived. This panel accepts messages only from{" "}
535
+ <code className="font-mono text-slate-300">{studioOrigin}</code> — if
536
+ Studio is running somewhere else, that is why.
537
+ </p>
538
+ ) : null}
539
+ </div>
540
+ );
541
+ }
542
+
543
+ // `preview` is stamped here, by the bridge, for the same reason the site key is
544
+ // stamped in the collect handler: the guarantee that makes this bridge's wider
545
+ // origin allow-list acceptable must not be re-derived at each call site. The
546
+ // caller supplied the component; every prop it is rendered with comes from here.
547
+ return (
548
+ <FormComponent config={config} locale={locale} theme={theme} preview />
549
+ );
550
+ }
@@ -0,0 +1,103 @@
1
+ import { NextResponse } from "next/server";
2
+ import { revalidatePath, revalidateTag } from "next/cache";
3
+ import { verify as cryptoVerify, createPublicKey } from "node:crypto";
4
+
5
+ /**
6
+ * Studio → site content revalidation webhook (spec: Feature B2 — asymmetric).
7
+ *
8
+ * Studio POSTs a JSON body signed with its Ed25519 PRIVATE key
9
+ * (`x-wmm-signature` base64, `x-wmm-kid` identifying the key). We verify with
10
+ * the PUBLIC key fetched from Studio's public API — no shared secret to
11
+ * configure or mismatch, and holding only a public key means a leak here is
12
+ * harmless. A valid signature (plus a freshness check to stop replay) triggers
13
+ * revalidatePath so slot-backed pages re-render with new values in seconds.
14
+ *
15
+ * Only config needed: `studioUrl` (public, non-secret).
16
+ */
17
+
18
+ const FRESHNESS_MS = 5 * 60 * 1000;
19
+
20
+ export function createRevalidateHandler({
21
+ studioUrl,
22
+ }: {
23
+ studioUrl: string | null | undefined;
24
+ }): (request: Request) => Promise<Response> {
25
+ async function fetchPublicKey(kid: string): Promise<string | null> {
26
+ const base = studioUrl?.replace(/\/+$/, "");
27
+ if (!base) return null;
28
+ try {
29
+ const res = await fetch(`${base}/api/public/revalidate-key`, {
30
+ next: { revalidate: 3600, tags: ["wmm-revalidate-key"] },
31
+ });
32
+ if (!res.ok) return null;
33
+ const json = (await res.json()) as { kid?: string; publicKey?: string };
34
+ if (kid && json.kid && kid !== json.kid) return null;
35
+ return json.publicKey ?? null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ return async function POST(request: Request) {
42
+ const signature = request.headers.get("x-wmm-signature") ?? "";
43
+ const kid = request.headers.get("x-wmm-kid") ?? "";
44
+ const raw = await request.text();
45
+
46
+ if (!signature) {
47
+ return NextResponse.json({ error: "missing signature" }, { status: 401 });
48
+ }
49
+
50
+ const publicKeyB64 = await fetchPublicKey(kid);
51
+ if (!publicKeyB64) {
52
+ return NextResponse.json({ error: "verification key unavailable" }, { status: 503 });
53
+ }
54
+
55
+ let valid = false;
56
+ try {
57
+ const key = createPublicKey({
58
+ key: Buffer.from(publicKeyB64, "base64"),
59
+ format: "der",
60
+ type: "spki",
61
+ });
62
+ valid = cryptoVerify(null, Buffer.from(raw, "utf8"), key, Buffer.from(signature, "base64"));
63
+ } catch {
64
+ valid = false;
65
+ }
66
+ if (!valid) {
67
+ return NextResponse.json({ error: "invalid signature" }, { status: 401 });
68
+ }
69
+
70
+ // Replay protection: the body carries an ISO `at`; reject stale/future ones.
71
+ try {
72
+ const body = JSON.parse(raw) as { at?: string };
73
+ if (body.at) {
74
+ const skew = Date.now() - new Date(body.at).getTime();
75
+ if (skew > FRESHNESS_MS || skew < -FRESHNESS_MS) {
76
+ return NextResponse.json({ error: "stale request" }, { status: 401 });
77
+ }
78
+ }
79
+ } catch {
80
+ // Body isn't JSON but the signature verified — allow (defensive).
81
+ }
82
+
83
+ // Bust the override FETCH, then the pages that embedded it — in that order,
84
+ // since revalidating pages against a still-cached override map re-renders them
85
+ // with the values we are trying to replace.
86
+ //
87
+ // `revalidateTag` is not redundant with `revalidatePath`. Path revalidation
88
+ // reaches fetches Next can attribute to a page under that layout, which covers
89
+ // the rendered site. It does NOT reach a fetch made from a route handler, and
90
+ // /api/ghl/studio-form does exactly that on every submission: resolveFormConfig
91
+ // → getSlot → this same tagged fetch. Without the tag, the SUBMIT path keeps
92
+ // validating against the previous config for up to the 30s TTL in content/payload —
93
+ // so a newly required field, a changed GHL mapping, or a form just unlisted
94
+ // would be enforced late, in the money path.
95
+ // `{ expire: 0 }` rather than a named cacheLife profile: Next 16's
96
+ // revalidateTag takes a staleness profile, and every named one still permits
97
+ // serving a stale entry for some window. A publish webhook wants the entry gone
98
+ // now — tolerating staleness here is the bug being fixed, not a tuning knob.
99
+ revalidateTag("wmm-content", { expire: 0 });
100
+ revalidatePath("/", "layout");
101
+ return NextResponse.json({ ok: true });
102
+ };
103
+ }