@zalify/storefront-kit 0.3.2 → 0.5.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,333 @@
1
+ "use client";
2
+ /**
3
+ * Editor preview for storefronts that render templates on the server (React
4
+ * Server Components) instead of through the client theme engine.
5
+ *
6
+ * The app supplies two server functions — one returning its bootstrap, one
7
+ * rendering a draft template to React nodes — and wraps each editable region.
8
+ * Everything else (trust, bridge, design mode, selection, responsive-copy
9
+ * paths, error surface) is here, so a storefront repo never writes it again.
10
+ *
11
+ * <EditorPreviewProvider getBootstrap={…} renderTemplate={…}>
12
+ * <EditorGroupRegion name="header-group">…</EditorGroupRegion>
13
+ * <EditorTemplateRegion name="page.about">…</EditorTemplateRegion>
14
+ * </EditorPreviewProvider>
15
+ */
16
+ import {
17
+ createContext,
18
+ type ReactNode,
19
+ useContext,
20
+ useEffect,
21
+ useRef,
22
+ useState,
23
+ } from "react";
24
+ import { flushSync } from "react-dom";
25
+ import type { FrameBridgeController } from "../editor/frame";
26
+ import type { EditorBootstrap } from "../schemas/bridge";
27
+ import type {
28
+ SectionGroupData,
29
+ SettingsData,
30
+ TemplateData,
31
+ } from "../schemas/data";
32
+ import { enterDesignMode } from "./design-mode";
33
+ import { resolveEditorOrigin, ZALIFY_EDITOR_ORIGINS } from "./editor-origin";
34
+
35
+ export interface RenderTemplateInput {
36
+ templateName: string;
37
+ template: TemplateData;
38
+ groups?: Record<string, SectionGroupData>;
39
+ /** Path + query of the page being previewed, without the editor param. */
40
+ pathname: string;
41
+ }
42
+
43
+ /** Keys are `template:<name>` and `group:<name>`. */
44
+ export type RenderedRegions = Record<string, ReactNode>;
45
+
46
+ export interface EditorPreviewProviderProps {
47
+ children: ReactNode;
48
+ /** Server function: the app's bootstrap (see `createEditorBootstrap`). */
49
+ getBootstrap: () => Promise<EditorBootstrap>;
50
+ /** Server function: render a draft. Must be read-only. */
51
+ renderTemplate: (input: RenderTemplateInput) => Promise<RenderedRegions>;
52
+ /** Map draft theme settings onto the page (CSS variables, usually). */
53
+ applySettings?: (settings: SettingsData, root: HTMLElement) => void;
54
+ /** Defaults to {@link ZALIFY_EDITOR_ORIGINS}. */
55
+ origins?: readonly string[];
56
+ /** A render slower than this fails the edit instead of hanging it. */
57
+ renderTimeoutMs?: number;
58
+ }
59
+
60
+ type PreviewContextValue = {
61
+ enabled: boolean;
62
+ selectedPath: string | null;
63
+ regions: RenderedRegions;
64
+ register: (name: string) => () => void;
65
+ };
66
+ const PreviewContext = createContext<PreviewContextValue | null>(null);
67
+
68
+ const CONNECT_TIMEOUT_MS = 30_000;
69
+ const GUARDED_EVENTS = ["click", "auxclick", "dblclick", "submit"];
70
+
71
+ async function withDeadline<T>(request: Promise<T>, ms: number): Promise<T> {
72
+ let timer: ReturnType<typeof setTimeout> | undefined;
73
+ try {
74
+ return await Promise.race([
75
+ request,
76
+ new Promise<never>((_, reject) => {
77
+ timer = setTimeout(
78
+ () => reject(new Error("Preview update timed out. Retry the edit.")),
79
+ ms,
80
+ );
81
+ }),
82
+ ]);
83
+ } finally {
84
+ clearTimeout(timer);
85
+ }
86
+ }
87
+
88
+ /** No bootstrap payload or bridge code is downloaded by ordinary visitors. */
89
+ export function EditorPreviewProvider({
90
+ children,
91
+ getBootstrap,
92
+ renderTemplate,
93
+ applySettings,
94
+ origins = ZALIFY_EDITOR_ORIGINS,
95
+ renderTimeoutMs = 20_000,
96
+ }: EditorPreviewProviderProps) {
97
+ const [editorOrigin, setEditorOrigin] = useState<string | null>(null);
98
+ const [selectedPath, setSelectedPath] = useState<string | null>(null);
99
+ const [templateName, setTemplateName] = useState<string | null>(null);
100
+ const [regions, setRegions] = useState<RenderedRegions>({});
101
+ const [error, setError] = useState<string | null>(null);
102
+ const [retry, setRetry] = useState(0);
103
+ const controllerRef = useRef<FrameBridgeController | null>(null);
104
+ const sequenceRef = useRef(0);
105
+ // Server functions and callbacks may be new identities on every render;
106
+ // they must not reconnect the bridge.
107
+ const handlers = useRef({ getBootstrap, renderTemplate, applySettings });
108
+ handlers.current = { getBootstrap, renderTemplate, applySettings };
109
+ const enabled = editorOrigin !== null;
110
+
111
+ useEffect(() => {
112
+ setEditorOrigin(resolveEditorOrigin(origins));
113
+ }, [origins]);
114
+
115
+ useEffect(() => {
116
+ if (!enabled) return;
117
+ const guard = (event: Event) => {
118
+ if (
119
+ event.type === "click" &&
120
+ event.target instanceof Element &&
121
+ event.target.closest("[data-z-editor-retry]")
122
+ ) {
123
+ event.preventDefault();
124
+ event.stopImmediatePropagation();
125
+ setRetry((value) => value + 1);
126
+ return;
127
+ }
128
+ // The bridge owns clicks once connected. Until then nothing in the shop
129
+ // may run; and a preview never submits a real lead, order or login.
130
+ if (event.type === "submit" || !controllerRef.current) {
131
+ event.preventDefault();
132
+ event.stopImmediatePropagation();
133
+ }
134
+ };
135
+ for (const type of GUARDED_EVENTS)
136
+ document.addEventListener(type, guard, true);
137
+ return () => {
138
+ for (const type of GUARDED_EVENTS)
139
+ document.removeEventListener(type, guard, true);
140
+ };
141
+ }, [enabled]);
142
+
143
+ // biome-ignore lint/correctness/useExhaustiveDependencies: retry intentionally tears down and reconnects the bridge.
144
+ useEffect(() => {
145
+ if (!editorOrigin || !templateName) return;
146
+ let disposed = false;
147
+ let unmountObservers = () => {};
148
+ const leaveDesignMode = enterDesignMode();
149
+ setError(null);
150
+ const timeout = window.setTimeout(
151
+ () =>
152
+ setError("Editor connection timed out. Retry the preview connection."),
153
+ CONNECT_TIMEOUT_MS,
154
+ );
155
+ void (async () => {
156
+ const [{ mountFrameBridge }, selection, visiblePaths, bootstrap] =
157
+ await Promise.all([
158
+ import("../editor/frame"),
159
+ import("../editor/selection"),
160
+ import("../editor/visible-paths"),
161
+ handlers.current.getBootstrap(),
162
+ ]);
163
+ if (disposed) return;
164
+ // Both must listen before the bridge does: selection so a component can
165
+ // reveal the selected tab before it is measured, visible paths so a
166
+ // hidden responsive copy never shadows the visible one.
167
+ const unmountSelection = selection.mountEditorSelection(window, (path) =>
168
+ flushSync(() => setSelectedPath(path)),
169
+ );
170
+ const unmountVisiblePaths = visiblePaths.mountVisiblePaths(window, () =>
171
+ controllerRef.current?.reportRects(),
172
+ );
173
+ unmountObservers = () => {
174
+ unmountVisiblePaths();
175
+ unmountSelection();
176
+ };
177
+ window.clearTimeout(timeout);
178
+ setError(null);
179
+ controllerRef.current = mountFrameBridge({
180
+ editorOrigin,
181
+ templateName,
182
+ hash: bootstrap.manifest.hash,
183
+ capabilities: [
184
+ "editor-bootstrap-v1",
185
+ "apply-template-v1",
186
+ "apply-groups-v1",
187
+ "apply-settings-v1",
188
+ "preview-navigation-v1",
189
+ ],
190
+ getManifest: () => bootstrap.manifest,
191
+ getBootstrap: () => bootstrap,
192
+ applyTemplate: async (payload) => {
193
+ if (payload.templateName !== templateName) return false;
194
+ const sequence = ++sequenceRef.current;
195
+ try {
196
+ const content = await withDeadline(
197
+ handlers.current.renderTemplate({
198
+ templateName,
199
+ template: payload.template,
200
+ groups: payload.groups,
201
+ pathname: window.location.pathname + window.location.search,
202
+ }),
203
+ renderTimeoutMs,
204
+ );
205
+ // A newer edit is already rendering: this result is stale.
206
+ if (disposed || sequence !== sequenceRef.current) return true;
207
+ setRegions((current) => ({ ...current, ...content }));
208
+ if (payload.settingsData)
209
+ handlers.current.applySettings?.(
210
+ payload.settingsData,
211
+ document.documentElement,
212
+ );
213
+ setError(null);
214
+ return true;
215
+ } catch (cause) {
216
+ if (!disposed && sequence === sequenceRef.current)
217
+ setError(
218
+ cause instanceof Error
219
+ ? cause.message
220
+ : "Preview could not update",
221
+ );
222
+ throw cause;
223
+ }
224
+ },
225
+ });
226
+ })().catch((cause: unknown) => {
227
+ window.clearTimeout(timeout);
228
+ if (!disposed)
229
+ setError(
230
+ cause instanceof Error ? cause.message : "Editor connection failed",
231
+ );
232
+ });
233
+ return () => {
234
+ disposed = true;
235
+ sequenceRef.current++;
236
+ window.clearTimeout(timeout);
237
+ controllerRef.current?.unmount();
238
+ controllerRef.current = null;
239
+ unmountObservers();
240
+ leaveDesignMode();
241
+ };
242
+ }, [editorOrigin, templateName, retry, renderTimeoutMs]);
243
+
244
+ // Measure after each committed region replacement.
245
+ // biome-ignore lint/correctness/useExhaustiveDependencies: regions is the trigger.
246
+ useEffect(() => {
247
+ controllerRef.current?.reportRects();
248
+ }, [regions]);
249
+
250
+ const register = (name: string) => {
251
+ setTemplateName(name);
252
+ return () =>
253
+ setTemplateName((current) => (current === name ? null : current));
254
+ };
255
+
256
+ return (
257
+ <PreviewContext.Provider
258
+ value={{ enabled, selectedPath, regions, register }}
259
+ >
260
+ {children}
261
+ {enabled && error ? (
262
+ <div
263
+ role="alert"
264
+ style={{
265
+ position: "fixed",
266
+ left: 16,
267
+ right: 16,
268
+ bottom: 16,
269
+ zIndex: 2147483647,
270
+ padding: 16,
271
+ background: "#fff1f0",
272
+ color: "#8b1b16",
273
+ border: "1px solid #c63225",
274
+ }}
275
+ >
276
+ {error}{" "}
277
+ <button
278
+ type="button"
279
+ data-z-editor-retry
280
+ onClick={() => setRetry((value) => value + 1)}
281
+ >
282
+ Retry
283
+ </button>
284
+ </div>
285
+ ) : null}
286
+ </PreviewContext.Provider>
287
+ );
288
+ }
289
+
290
+ function useRegion(key: string, children: ReactNode): ReactNode {
291
+ const context = useContext(PreviewContext);
292
+ return context?.enabled && Object.hasOwn(context.regions, key)
293
+ ? context.regions[key]
294
+ : children;
295
+ }
296
+
297
+ /** The page's template. Also tells the provider which template this route is. */
298
+ export function EditorTemplateRegion({
299
+ name,
300
+ children,
301
+ }: {
302
+ name: string;
303
+ children: ReactNode;
304
+ }) {
305
+ const context = useContext(PreviewContext);
306
+ // Registration follows route identity, not every update of preview state.
307
+ const registerRef = useRef(context?.register);
308
+ registerRef.current = context?.register;
309
+ useEffect(() => registerRef.current?.(name), [name]);
310
+ return useRegion(`template:${name}`, children);
311
+ }
312
+
313
+ /** A section group (header-group, footer-group). */
314
+ export function EditorGroupRegion({
315
+ name,
316
+ children,
317
+ }: {
318
+ name: string;
319
+ children: ReactNode;
320
+ }) {
321
+ return useRegion(`group:${name}`, children);
322
+ }
323
+
324
+ /** True inside a trusted editor frame, after hydration. */
325
+ export function useEditorPreview(): boolean {
326
+ return useContext(PreviewContext)?.enabled ?? false;
327
+ }
328
+
329
+ /** The editor's selected `data-z-path`, so a component can reveal it. */
330
+ export function useEditorSelection(): string | null {
331
+ const context = useContext(PreviewContext);
332
+ return context?.enabled ? context.selectedPath : null;
333
+ }
@@ -6,10 +6,14 @@ import type { FrameBridgeController } from "../editor/frame";
6
6
  import type { PreviewContext } from "../schemas/bridge";
7
7
  import type { EditorDocuments } from "../editor/bootstrap";
8
8
  import { getThemeStore, setThemePreview } from "./engine/store";
9
- import { resolveEditorOrigin } from "./editor-origin";
9
+ import { resolveEditorOrigin, ZALIFY_EDITOR_ORIGINS } from "./editor-origin";
10
+
11
+ export { ZALIFY_EDITOR_ORIGINS };
12
+ import { enterDesignMode } from "./design-mode";
10
13
 
11
14
  type Options = {
12
- origins: readonly string[];
15
+ /** Defaults to {@link ZALIFY_EDITOR_ORIGINS}. */
16
+ origins?: readonly string[];
13
17
  loadManifest: () => Promise<ThemeEditorManifest>;
14
18
  /** Authoritative, repository-relative merchant data targets. */
15
19
  paths: EditorDocuments;
@@ -29,8 +33,11 @@ export function useEditorTemplate(
29
33
  const controller = useRef<FrameBridgeController | null>(null);
30
34
  useEffect(() => {
31
35
  setDraft(null);
32
- const parentOrigin = resolveEditorOrigin(options.origins);
36
+ const parentOrigin = resolveEditorOrigin(
37
+ options.origins ?? ZALIFY_EDITOR_ORIGINS,
38
+ );
33
39
  if (!parentOrigin) return;
40
+ const leaveDesignMode = enterDesignMode();
34
41
  let disposed = false;
35
42
  const owner = getThemeStore();
36
43
  void Promise.all([
@@ -114,6 +121,7 @@ export function useEditorTemplate(
114
121
  });
115
122
  return () => {
116
123
  disposed = true;
124
+ leaveDesignMode();
117
125
  controller.current?.unmount();
118
126
  controller.current = null;
119
127
  setThemePreview(owner, null);