@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,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's origin can'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's origin can'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'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
|
+
}
|