@zalify/storefront-kit 0.1.12 → 0.2.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.
Files changed (65) hide show
  1. package/dist/commerce/disclosure-motion.d.ts +2 -0
  2. package/dist/commerce/disclosure-motion.js +69 -0
  3. package/dist/commerce/image-url.d.ts +2 -0
  4. package/dist/commerce/image-url.js +15 -0
  5. package/dist/commerce/index.d.ts +1 -0
  6. package/dist/commerce/index.js +1 -0
  7. package/dist/editor/bootstrap.d.ts +15 -0
  8. package/dist/editor/bootstrap.js +42 -0
  9. package/dist/editor/draft.d.ts +17 -0
  10. package/dist/editor/draft.js +32 -0
  11. package/dist/editor/frame.d.ts +2 -0
  12. package/dist/editor/frame.js +7 -4
  13. package/dist/react/adapter.d.ts +3 -0
  14. package/dist/react/adapter.js +1 -0
  15. package/dist/react/blocks/_slide.js +2 -1
  16. package/dist/react/blocks/_video-card.js +2 -1
  17. package/dist/react/blocks/image.js +2 -1
  18. package/dist/react/blocks/story.js +2 -1
  19. package/dist/react/blocks/video.js +2 -1
  20. package/dist/react/components/ProductCard.js +4 -3
  21. package/dist/react/components/StoreImage.d.ts +2 -0
  22. package/dist/react/components/StoreImage.js +6 -0
  23. package/dist/react/components/VideoModal.js +2 -1
  24. package/dist/react/engine/CssVariables.js +5 -4
  25. package/dist/react/engine/images.d.ts +1 -1
  26. package/dist/react/engine/images.js +2 -6
  27. package/dist/react/engine/render.js +3 -1
  28. package/dist/react/engine/store.d.ts +6 -0
  29. package/dist/react/engine/store.js +34 -3
  30. package/dist/react/index.d.ts +1 -0
  31. package/dist/react/index.js +1 -0
  32. package/dist/react/sections/article.js +2 -1
  33. package/dist/react/sections/custom-section.js +2 -1
  34. package/dist/react/sections/image-with-text.js +2 -1
  35. package/dist/react/sections/video-bubble.js +2 -1
  36. package/dist/react/useEditorTemplate.d.ts +15 -0
  37. package/dist/react/useEditorTemplate.js +114 -0
  38. package/dist/schemas/bridge.d.ts +1 -0
  39. package/package.json +1 -1
  40. package/src/commerce/disclosure-motion.ts +60 -0
  41. package/src/commerce/image-url.ts +11 -0
  42. package/src/commerce/index.ts +2 -0
  43. package/src/editor/bootstrap.ts +61 -0
  44. package/src/editor/draft.ts +54 -0
  45. package/src/editor/frame.ts +7 -3
  46. package/src/react/adapter.tsx +4 -0
  47. package/src/react/blocks/_slide.tsx +2 -1
  48. package/src/react/blocks/_video-card.tsx +3 -2
  49. package/src/react/blocks/image.tsx +2 -1
  50. package/src/react/blocks/story.tsx +2 -1
  51. package/src/react/blocks/video.tsx +2 -1
  52. package/src/react/components/ProductCard.tsx +6 -5
  53. package/src/react/components/StoreImage.tsx +6 -0
  54. package/src/react/components/VideoModal.tsx +3 -2
  55. package/src/react/engine/CssVariables.tsx +34 -10
  56. package/src/react/engine/images.tsx +2 -5
  57. package/src/react/engine/render.tsx +14 -1
  58. package/src/react/engine/store.ts +43 -4
  59. package/src/react/index.ts +2 -0
  60. package/src/react/sections/article.tsx +2 -1
  61. package/src/react/sections/custom-section.tsx +2 -1
  62. package/src/react/sections/image-with-text.tsx +2 -1
  63. package/src/react/sections/video-bubble.tsx +2 -1
  64. package/src/react/useEditorTemplate.ts +136 -0
  65. package/src/schemas/bridge.ts +1 -0
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { StoreImage } from '../components/StoreImage';
2
3
  import { imageUrl, settingImageUrl } from '../engine/images';
3
4
  export default function CustomSection({ settings, children, }) {
4
5
  const { color_scheme: colorScheme = 'scheme-1', section_spacing: sectionSpacing = 'md', } = settings;
5
6
  const backgroundImage = settingImageUrl(settings.background_image);
6
7
  return (_jsxs("div", { className: `custom-section section full-width color-${colorScheme}`, style: {
7
8
  '--section-spacing': `var(--space-section-${sectionSpacing})`,
8
- }, children: [backgroundImage ? (_jsx("div", { className: "custom-section__background", children: _jsx("img", { src: imageUrl(backgroundImage, 2000), alt: "" }) })) : null, _jsx("div", { className: "custom-section__content", children: children })] }));
9
+ }, children: [backgroundImage ? (_jsx("div", { className: "custom-section__background", children: _jsx(StoreImage, { src: imageUrl(backgroundImage, 2000), alt: "" }) })) : null, _jsx("div", { className: "custom-section__content", children: children })] }));
9
10
  }
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { StoreImage } from '../components/StoreImage';
2
3
  import { imageSrcSet, imageUrl, PlaceholderSvg, settingImageUrl, } from '../engine/images';
3
4
  export default function ImageWithTextSection({ settings, children, }) {
4
5
  const { image_position: imagePosition = 'left', color_scheme: colorScheme = 'scheme-1', section_spacing: sectionSpacing = 'lg', } = settings;
5
6
  const image = settingImageUrl(settings.image);
6
7
  return (_jsx("div", { className: `image-with-text section color-${colorScheme} full-width`, style: {
7
8
  '--section-spacing': `var(--space-section-${sectionSpacing})`,
8
- }, children: _jsxs("div", { className: `image-with-text__inner image-with-text--media-${imagePosition}`, children: [_jsx("div", { className: "image-with-text__media", children: image ? (_jsx("img", { className: "image-with-text__image", src: imageUrl(image, 1500), srcSet: imageSrcSet(image, [400, 700, 1000, 1500]), sizes: "(min-width: 48rem) 50vw, 100vw", loading: "lazy", alt: "" })) : (_jsx(PlaceholderSvg, { className: "image-with-text__image image-with-text__placeholder" })) }), _jsx("div", { className: "image-with-text__content stack", children: children })] }) }));
9
+ }, children: _jsxs("div", { className: `image-with-text__inner image-with-text--media-${imagePosition}`, children: [_jsx("div", { className: "image-with-text__media", children: image ? (_jsx(StoreImage, { className: "image-with-text__image", src: imageUrl(image, 1500), srcSet: imageSrcSet(image, [400, 700, 1000, 1500]), sizes: "(min-width: 48rem) 50vw, 100vw", loading: "lazy", alt: "" })) : (_jsx(PlaceholderSvg, { className: "image-with-text__image image-with-text__placeholder" })) }), _jsx("div", { className: "image-with-text__content stack", children: children })] }) }));
9
10
  }
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { StoreImage } from '../components/StoreImage';
2
3
  /**
3
4
  * Port of sections/video-bubble.liquid — floating video bubble: a small
4
5
  * muted preview loop pinned to a bottom corner that expands into the
@@ -48,7 +49,7 @@ export default function VideoBubbleSection({ id, settings, }) {
48
49
  // Storage can be unavailable (private mode); hiding is enough
49
50
  }
50
51
  };
51
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: `video-bubble video-bubble--${position} video-bubble--${shape}`, style: { '--bubble-size': `${size}px` }, "data-dismiss-key": id, children: [_jsx("button", { type: "button", className: "video-bubble__dismiss", onClick: dismiss, "aria-label": t('general.close'), children: _jsx(Icon, { name: "icon-close" }) }), _jsxs("button", { type: "button", className: "video-bubble__trigger", onClick: () => setOpen(true), "aria-label": t('general.play_video'), children: [previewMp4 ? (_jsx("video", { ref: previewRef, className: "video-bubble__media", autoPlay: true, loop: true, muted: true, playsInline: true, preload: "metadata", "data-video-preview": "", poster: poster ? imageUrl(poster, 400) : undefined, children: _jsx("source", { src: previewMp4.url, type: previewMp4.mime_type }) })) : poster ? (_jsx("img", { className: "video-bubble__media", src: imageUrl(poster, 400), loading: "lazy", alt: "" })) : (_jsx(PlaceholderSvg, { className: "video-bubble__media video-bubble__media--placeholder" })), _jsx("span", { className: "video-bubble__play", children: _jsx(Icon, { name: "icon-play" }) })] }), _jsx(VideoModal, { open: open, onClose: () => setOpen(false), video: mainVideo, poster: poster, product: product })] }), _jsx("script", { dangerouslySetInnerHTML: {
52
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: `video-bubble video-bubble--${position} video-bubble--${shape}`, style: { '--bubble-size': `${size}px` }, "data-dismiss-key": id, children: [_jsx("button", { type: "button", className: "video-bubble__dismiss", onClick: dismiss, "aria-label": t('general.close'), children: _jsx(Icon, { name: "icon-close" }) }), _jsxs("button", { type: "button", className: "video-bubble__trigger", onClick: () => setOpen(true), "aria-label": t('general.play_video'), children: [previewMp4 ? (_jsx("video", { ref: previewRef, className: "video-bubble__media", autoPlay: true, loop: true, muted: true, playsInline: true, preload: "metadata", "data-video-preview": "", poster: poster ? imageUrl(poster, 400) : undefined, children: _jsx("source", { src: previewMp4.url, type: previewMp4.mime_type }) })) : poster ? (_jsx(StoreImage, { className: "video-bubble__media", src: imageUrl(poster, 400), loading: "lazy", alt: "" })) : (_jsx(PlaceholderSvg, { className: "video-bubble__media video-bubble__media--placeholder" })), _jsx("span", { className: "video-bubble__play", children: _jsx(Icon, { name: "icon-play" }) })] }), _jsx(VideoModal, { open: open, onClose: () => setOpen(false), video: mainVideo, poster: poster, product: product })] }), _jsx("script", { dangerouslySetInnerHTML: {
52
53
  __html: `(function(){try{if(sessionStorage.getItem(${JSON.stringify(dismissKey)})){document.currentScript.previousElementSibling.hidden=true;}}catch(e){}})();`,
53
54
  } })] }));
54
55
  }
@@ -0,0 +1,15 @@
1
+ import type { TemplateData } from "../schemas/data";
2
+ import type { ThemeEditorManifest } from "../schemas/manifest";
3
+ import type { PreviewContext } from "../schemas/bridge";
4
+ import type { EditorDocuments } from "../editor/bootstrap";
5
+ type Options = {
6
+ origins: readonly string[];
7
+ loadManifest: () => Promise<ThemeEditorManifest>;
8
+ /** Authoritative, repository-relative merchant data targets. */
9
+ paths: EditorDocuments;
10
+ /** Real route targets keyed by template name. */
11
+ previews: Record<string, PreviewContext>;
12
+ };
13
+ /** No editor code or schema is fetched outside an explicitly allowed preview. */
14
+ export declare function useEditorTemplate(name: string, options: Options): TemplateData | null;
15
+ export {};
@@ -0,0 +1,114 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { getThemeStore, setThemePreview } from "./engine/store";
4
+ /** No editor code or schema is fetched outside an explicitly allowed preview. */
5
+ export function useEditorTemplate(name, options) {
6
+ const [draft, setDraft] = useState(null);
7
+ const controller = useRef(null);
8
+ useEffect(() => {
9
+ setDraft(null);
10
+ if (!options.origins.length ||
11
+ window.self === window.top ||
12
+ new URL(window.location.href).searchParams.get("zalify-editor") !== "1")
13
+ return;
14
+ let parentOrigin;
15
+ try {
16
+ parentOrigin = new URL(document.referrer).origin;
17
+ }
18
+ catch {
19
+ return;
20
+ }
21
+ if (!options.origins.includes(parentOrigin))
22
+ return;
23
+ let disposed = false;
24
+ const owner = getThemeStore();
25
+ void Promise.all([
26
+ import("../editor/frame"),
27
+ options.loadManifest(),
28
+ import("../editor/bootstrap"),
29
+ import("../editor/draft"),
30
+ ])
31
+ .then(([bridge, manifest, bootstrapModule, draftModule]) => {
32
+ if (disposed)
33
+ return;
34
+ const settings = owner.schema.settingsData;
35
+ const current = typeof settings.current === "string"
36
+ ? (settings.presets?.[settings.current] ?? {})
37
+ : settings.current;
38
+ const schema = {
39
+ ...owner.schema,
40
+ settingsData: {
41
+ ...settings,
42
+ current: { ...current, ...owner.settingsOverride },
43
+ },
44
+ };
45
+ const previews = { ...options.previews };
46
+ // The currently rendered route is also a verified context, including locale.
47
+ const url = new URL(window.location.href);
48
+ url.searchParams.delete("zalify-editor");
49
+ const kind = name.split(".")[0];
50
+ const resourceType = ([
51
+ "index",
52
+ "product",
53
+ "collection",
54
+ "page",
55
+ "blog",
56
+ "article",
57
+ "cart",
58
+ "search",
59
+ "list-collections",
60
+ "404",
61
+ ].includes(kind)
62
+ ? kind
63
+ : "page");
64
+ previews[name] = {
65
+ ...previews[name],
66
+ id: name,
67
+ title: previews[name]?.title ?? name,
68
+ resourceType,
69
+ url: url.pathname + url.search,
70
+ };
71
+ const bootstrap = bootstrapModule.createEditorBootstrap({
72
+ schema,
73
+ manifest,
74
+ paths: options.paths,
75
+ previews,
76
+ });
77
+ const draft = draftModule.createEditorDraft(schema);
78
+ controller.current = bridge.mountFrameBridge({
79
+ editorOrigin: parentOrigin,
80
+ templateName: name,
81
+ hash: manifest.hash,
82
+ getManifest: () => manifest,
83
+ capabilities: [
84
+ "editor-bootstrap-v1",
85
+ "apply-template-v1",
86
+ "apply-groups-v1",
87
+ "apply-settings-v1",
88
+ "preview-navigation-v1",
89
+ ],
90
+ getBootstrap: () => bootstrap,
91
+ applyTemplate(payload) {
92
+ if (payload.templateName !== name)
93
+ return false;
94
+ setThemePreview(owner, { ...schema, ...draft.apply(payload) });
95
+ setDraft({ name, template: payload.template });
96
+ return true;
97
+ },
98
+ });
99
+ })
100
+ .catch(() => {
101
+ /* The storefront remains usable if preview assets fail. */
102
+ });
103
+ return () => {
104
+ disposed = true;
105
+ controller.current?.unmount();
106
+ controller.current = null;
107
+ setThemePreview(owner, null);
108
+ };
109
+ }, [name, options]);
110
+ useEffect(() => {
111
+ controller.current?.reportRects();
112
+ }, [draft]);
113
+ return draft?.name === name ? draft.template : null;
114
+ }
@@ -121,6 +121,7 @@ export type FrameMessage = BridgeEnvelope<'bridge:ready', {
121
121
  deltaX: number;
122
122
  deltaY: number;
123
123
  ctrlKey: boolean;
124
+ shiftKey?: boolean;
124
125
  clientX: number;
125
126
  clientY: number;
126
127
  }> | BridgeEnvelope<'navigation', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalify/storefront-kit",
3
- "version": "0.1.12",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "The Zalify storefront SDK: framework-agnostic commerce logic (/commerce), the theme contract types and validators (/schemas), the canvas-editor bridge (/editor), and the React theme engine + shared components (/ui, /react/server). Consumed as TypeScript source inside the zalify-storefronts monorepo; published as compiled ESM + d.ts.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -0,0 +1,60 @@
1
+ /** Progressive native disclosure motion. Returns listener/animation cleanup. */
2
+ export function initDisclosureMotion() {
3
+
4
+ const pointerMode = () => { document.documentElement.dataset.inputMode = 'pointer'; };
5
+ const keyboardMode = (event: KeyboardEvent) => { if (event.key === 'Tab' || event.key.startsWith('Arrow')) document.documentElement.dataset.inputMode = 'keyboard'; };
6
+ document.addEventListener('pointerdown', pointerMode, true);
7
+ document.addEventListener('keydown', keyboardMode, true);
8
+ const reduced = matchMedia('(prefers-reduced-motion: reduce)');
9
+ const running = new Map<HTMLDetailsElement, {animation: Animation; expanded: boolean; cleanup: () => void}>();
10
+ const toggle = (details: HTMLDetailsElement, expanded: boolean) => {
11
+ const from = details.getBoundingClientRect().height;
12
+ running.get(details)?.cleanup();
13
+ const summary = details.querySelector('summary');
14
+ if (!summary) return;
15
+ const originalHeight = details.style.height;
16
+ const originalOverflow = details.style.overflow;
17
+ const name = details.getAttribute('name');
18
+ // Hold named siblings open only long enough to finish their closing motion.
19
+ if (!expanded && name) details.removeAttribute('name');
20
+ details.toggleAttribute('data-disclosure-closing', !expanded);
21
+ details.open = true;
22
+ const to = expanded ? details.getBoundingClientRect().height : summary.getBoundingClientRect().height +
23
+ parseFloat(getComputedStyle(details).paddingTop || '0') + parseFloat(getComputedStyle(details).paddingBottom || '0') +
24
+ parseFloat(getComputedStyle(details).borderTopWidth || '0') + parseFloat(getComputedStyle(details).borderBottomWidth || '0');
25
+ details.style.overflow = 'hidden';
26
+ const animation = details.animate([{height: `${from}px`}, {height: `${to}px`}], {duration: 280, easing: 'cubic-bezier(.22, 1, .36, 1)'});
27
+ const cleanup = () => {
28
+ animation.onfinish = null;
29
+ animation.cancel();
30
+ details.removeAttribute('data-disclosure-closing');
31
+ details.style.height = originalHeight;
32
+ details.style.overflow = originalOverflow;
33
+ if (name) details.setAttribute('name', name);
34
+ running.delete(details);
35
+ };
36
+ running.set(details, {animation, expanded, cleanup});
37
+ animation.onfinish = () => { details.open = expanded; cleanup(); };
38
+ };
39
+ const onClick = (event: MouseEvent) => {
40
+ if (event.defaultPrevented || reduced.matches || event.button !== 0) return;
41
+ const target = event.target instanceof Element ? event.target : null;
42
+ const summary = target?.closest('summary');
43
+ const details = summary?.parentElement;
44
+ if (!(details instanceof HTMLDetailsElement) || !summary || getComputedStyle(summary).display === 'none') return;
45
+ if (target?.closest('a, button, input, select, textarea')) return;
46
+ event.preventDefault();
47
+ const expanded = !(running.get(details)?.expanded ?? details.open);
48
+ if (expanded && details.name) {
49
+ document.querySelectorAll<HTMLDetailsElement>('details[open]').forEach(peer => {
50
+ if (peer !== details && peer.name === details.name) toggle(peer, false);
51
+ });
52
+ }
53
+ toggle(details, expanded);
54
+ };
55
+ const stop = () => { for (const [details, state] of [...running]) { details.open = state.expanded; state.cleanup(); } };
56
+ document.addEventListener('click', onClick);
57
+ reduced.addEventListener('change', stop);
58
+ return () => { document.removeEventListener('pointerdown', pointerMode, true); document.removeEventListener('keydown', keyboardMode, true); delete document.documentElement.dataset.inputMode; document.removeEventListener('click', onClick); reduced.removeEventListener('change', stop); stop(); };
59
+
60
+ }
@@ -0,0 +1,11 @@
1
+ /** Replace Shopify width parameters while preserving version hashes and crops. */
2
+ export function imageUrl(url: string, width?: number): string {
3
+ if (!width || !url.includes('cdn.shopify.com')) return url;
4
+ try {
5
+ const parsed = new URL(url.startsWith('//') ? `https:${url}` : url);
6
+ if (parsed.hostname !== 'cdn.shopify.com') return url;
7
+ parsed.searchParams.set('width', String(width));
8
+ return parsed.toString();
9
+ } catch { return url; }
10
+ }
11
+
@@ -10,3 +10,5 @@ export * from './countdown';
10
10
  export * from './facets';
11
11
  export * from './product';
12
12
  export * from './google-fonts';
13
+
14
+ export {initDisclosureMotion} from './disclosure-motion';
@@ -0,0 +1,61 @@
1
+ import type {EditorBootstrap, PreviewContext} from '../schemas/bridge.ts';
2
+ import type {ThemeEditorManifest} from '../schemas/manifest.ts';
3
+ import type {DraftSchema} from './draft.ts';
4
+
5
+ export interface EditorDocuments {
6
+ templates: string;
7
+ groups: string;
8
+ settings: string;
9
+ }
10
+
11
+ /** Only list routes the app can actually render; never fabricate handles. */
12
+ export function createEditorBootstrap(options: {
13
+ schema: DraftSchema;
14
+ manifest: ThemeEditorManifest;
15
+ paths: EditorDocuments;
16
+ previews: Record<string, PreviewContext>;
17
+ }): EditorBootstrap {
18
+ const {schema, manifest, paths, previews} = options;
19
+ for (const path of [paths.templates, paths.groups, paths.settings]) {
20
+ if (
21
+ !path ||
22
+ path.startsWith('/') ||
23
+ path.includes('\\') ||
24
+ path.split('/').some((p) => p === '..' || p === '.')
25
+ ) {
26
+ throw new Error('Editor write paths must be repository-relative');
27
+ }
28
+ }
29
+ const templates = {
30
+ ...schema.templates,
31
+ ...Object.fromEntries(
32
+ Object.entries(schema.customerTemplates ?? {}).map(([name, data]) => [
33
+ `customers/${name}`,
34
+ data,
35
+ ]),
36
+ ),
37
+ };
38
+ return {
39
+ revision: manifest.hash,
40
+ manifest,
41
+ templates: Object.entries(templates)
42
+ .filter(([name]) => previews[name])
43
+ .map(([name, data]) => ({
44
+ name,
45
+ data: structuredClone(data),
46
+ writePath: `${paths.templates}/${name}.json`,
47
+ preview: previews[name],
48
+ })),
49
+ groups: Object.entries(schema.sectionGroups).map(([name, data]) => ({
50
+ name,
51
+ data: structuredClone(data),
52
+ writePath: `${paths.groups}/${name}.json`,
53
+ })),
54
+ settings: {
55
+ writePath: paths.settings,
56
+ schema: manifest.settingsSchema,
57
+ resolvedData: structuredClone(schema.settingsData),
58
+ },
59
+ previewContexts: {},
60
+ };
61
+ }
@@ -0,0 +1,54 @@
1
+ import type {
2
+ SectionGroupData,
3
+ SettingsData,
4
+ TemplateData,
5
+ } from '../schemas/data.ts';
6
+
7
+ export interface DraftSchema {
8
+ templates: Record<string, TemplateData>;
9
+ customerTemplates?: Record<string, TemplateData>;
10
+ sectionGroups: Record<string, SectionGroupData>;
11
+ settingsData: SettingsData;
12
+ }
13
+
14
+ export interface PreviewApply {
15
+ templateName: string;
16
+ template: TemplateData;
17
+ groups?: Record<string, SectionGroupData>;
18
+ settingsData?: SettingsData;
19
+ }
20
+
21
+ /** Immutable, per-preview data. Never writes into an installed source schema. */
22
+ export function createEditorDraft(source: DraftSchema) {
23
+ let current = structuredClone(source);
24
+ return {
25
+ apply(payload: PreviewApply): DraftSchema {
26
+ const template = structuredClone(payload.template);
27
+ const customer = payload.templateName.startsWith('customers/');
28
+ current = {
29
+ ...current,
30
+ ...(customer
31
+ ? {
32
+ customerTemplates: {
33
+ ...current.customerTemplates,
34
+ [payload.templateName.slice(10)]: template,
35
+ },
36
+ }
37
+ : {
38
+ templates: {
39
+ ...current.templates,
40
+ [payload.templateName]: template,
41
+ },
42
+ }),
43
+ sectionGroups: {
44
+ ...current.sectionGroups,
45
+ ...structuredClone(payload.groups ?? {}),
46
+ },
47
+ settingsData: structuredClone(
48
+ payload.settingsData ?? current.settingsData,
49
+ ),
50
+ };
51
+ return current;
52
+ },
53
+ };
54
+ }
@@ -61,6 +61,8 @@ export interface FrameBridgeOptions {
61
61
  groups?: Record<string, SectionGroupData>;
62
62
  settingsData?: SettingsData;
63
63
  }) => boolean | Promise<boolean>;
64
+ /** Optional trusted editor origin, checked before the initial handshake. */
65
+ editorOrigin?: string;
64
66
  onDeviceChange?: (device: Device) => void;
65
67
  /** Overrides for tests / non-browser hosts. */
66
68
  window?: Window & typeof globalThis;
@@ -211,6 +213,8 @@ export function mountFrameBridge(
211
213
  };
212
214
 
213
215
  const handleMessage = async (event: MessageEvent): Promise<void> => {
216
+ if (event.source !== win.parent) return;
217
+ if (options.editorOrigin && event.origin !== options.editorOrigin) return;
214
218
  const data: unknown = event.data;
215
219
  if (!isBridgeMessage(data)) return;
216
220
  if (editorOrigin && event.origin !== editorOrigin) return;
@@ -322,9 +326,8 @@ export function mountFrameBridge(
322
326
  };
323
327
 
324
328
  const handleWheel = (event: WheelEvent): void => {
325
- // Both previews are fixed device viewports. Let ordinary wheel gestures
326
- // scroll the storefront; pinch/Ctrl+wheel remains a canvas zoom gesture.
327
- if (!event.ctrlKey) return;
329
+ // Cross-origin iframe wheel events never reach the host canvas. Forward
330
+ // wheel gestures so the editor can pan its canvas.
328
331
  event.preventDefault();
329
332
  post({
330
333
  type: 'viewport:wheel',
@@ -332,6 +335,7 @@ export function mountFrameBridge(
332
335
  deltaX: event.deltaX,
333
336
  deltaY: event.deltaY,
334
337
  ctrlKey: event.ctrlKey,
338
+ shiftKey: event.shiftKey,
335
339
  clientX: event.clientX,
336
340
  clientY: event.clientY,
337
341
  },
@@ -36,6 +36,8 @@ export interface CartAddFormProps {
36
36
  }
37
37
 
38
38
  export interface ThemeAdapter {
39
+ /** Optional responsive image renderer; hosts without one retain native images. */
40
+ Image?: ComponentType<React.ImgHTMLAttributes<HTMLImageElement>>;
39
41
  /** Client-side navigation link. */
40
42
  Link: ComponentType<AdapterLinkProps>;
41
43
  /** Hook returning an imperative navigate(url, opts) function. */
@@ -81,3 +83,5 @@ export function useAdapter(): ThemeAdapter {
81
83
  }
82
84
  return adapter;
83
85
  }
86
+
87
+ export function useAdapterImage() { return useContext(AdapterContext)?.Image; }
@@ -1,3 +1,4 @@
1
+ import {StoreImage} from '../components/StoreImage';
1
2
  /**
2
3
  * Port of blocks/_slide.liquid — hero slide: full-bleed media canvas.
3
4
  * Content lives in nested _box blocks. CSS: blocks-_slide.css
@@ -70,7 +71,7 @@ export default function SlideBlock({
70
71
  sizes="100vw"
71
72
  />
72
73
  )}
73
- <img
74
+ <StoreImage
74
75
  className="hero__media"
75
76
  src={imageUrl(image, 3000)}
76
77
  srcSet={[750, 1100, 1500, 2200, 3000]
@@ -1,3 +1,4 @@
1
+ import {StoreImage} from '../components/StoreImage';
1
2
  /**
2
3
  * Port of blocks/_video-card.liquid — video carousel card: a muted
3
4
  * preview loop that expands into the shared video modal (main video
@@ -83,7 +84,7 @@ export default function VideoCardBlock({
83
84
  <source src={previewMp4.url} type={previewMp4.mime_type} />
84
85
  </video>
85
86
  ) : poster ? (
86
- <img
87
+ <StoreImage
87
88
  className="video-card__media"
88
89
  src={imageUrl(poster, 800)}
89
90
  loading="lazy"
@@ -100,7 +101,7 @@ export default function VideoCardBlock({
100
101
  {product ? (
101
102
  <a className="video-card__product" href={product.url}>
102
103
  {productImage ? (
103
- <img
104
+ <StoreImage
104
105
  className="video-card__product-image"
105
106
  src={imageUrl(productImage, 96)}
106
107
  loading="lazy"
@@ -1,3 +1,4 @@
1
+ import {StoreImage} from '../components/StoreImage';
1
2
  /** Port of blocks/image.liquid. CSS: app/styles/components/blocks-image.css */
2
3
  import type {BlockProps} from '../engine/types';
3
4
  import {imageSrcSet, imageUrl, PlaceholderSvg, settingImageUrl} from '../engine/images';
@@ -13,7 +14,7 @@ export default function ImageBlock({settings}: BlockProps<ImageSettings>) {
13
14
  return (
14
15
  <div className={`image-block image-block--${aspect}`}>
15
16
  {src ? (
16
- <img
17
+ <StoreImage
17
18
  className="image-block__image"
18
19
  src={imageUrl(src, 1500)}
19
20
  srcSet={imageSrcSet(src)}
@@ -1,3 +1,4 @@
1
+ import {StoreImage} from '../components/StoreImage';
1
2
  /**
2
3
  * Port of the section-local `story` block from sections/stories.liquid:
3
4
  * a circular/square thumbnail with an optional label and link.
@@ -24,7 +25,7 @@ export default function StoryBlock({settings}: BlockProps<StorySettings>) {
24
25
  <a className="story" role="listitem" href={settings.link || undefined}>
25
26
  <span className="story__media">
26
27
  {image ? (
27
- <img
28
+ <StoreImage
28
29
  className="story__image"
29
30
  src={imageUrl(image, 480)}
30
31
  loading="lazy"
@@ -1,3 +1,4 @@
1
+ import {StoreImage} from '../components/StoreImage';
1
2
  /**
2
3
  * Port of blocks/video.liquid — general video block: a Shopify-hosted
3
4
  * file (ambient loop or click-to-play with native controls) or a
@@ -94,7 +95,7 @@ export default function VideoBlock({settings}: BlockProps<VideoSettings>) {
94
95
  hidden={playing}
95
96
  >
96
97
  {poster ? (
97
- <img
98
+ <StoreImage
98
99
  className="video-block__media"
99
100
  src={imageUrl(poster, 1500)}
100
101
  srcSet={[400, 700, 1000, 1500]
@@ -1,3 +1,4 @@
1
+ import {StoreImage} from './StoreImage';
1
2
  /**
2
3
  * Port of snippets/product-card.liquid — a product card for grids:
3
4
  * square media (second image on hover), title, price, and badges
@@ -67,7 +68,7 @@ export function ProductCard({product, loading = 'lazy'}: ProductCardProps) {
67
68
  : null;
68
69
 
69
70
  // Swatch-hover preview, crossfading over the base image. The Liquid
70
- // island creates the <img> on first hover; React renders it once a
71
+ // island creates the <StoreImage> on first hover; React renders it once a
71
72
  // swatch has actually been hovered.
72
73
  const [preview, setPreview] = useState<{src: string; visible: boolean} | null>(
73
74
  null,
@@ -170,7 +171,7 @@ export function ProductCard({product, loading = 'lazy'}: ProductCardProps) {
170
171
  >
171
172
  {featuredImage ? (
172
173
  <>
173
- <img
174
+ <StoreImage
174
175
  className="product-card__image"
175
176
  src={imageUrl(featuredImage.url, 800)}
176
177
  srcSet={cardSrcSet(featuredImage.url)}
@@ -184,7 +185,7 @@ export function ProductCard({product, loading = 'lazy'}: ProductCardProps) {
184
185
  height={featuredImage.height ?? undefined}
185
186
  />
186
187
  {secondaryImage ? (
187
- <img
188
+ <StoreImage
188
189
  className="product-card__image product-card__image--hover"
189
190
  src={imageUrl(secondaryImage.url, 800)}
190
191
  srcSet={cardSrcSet(secondaryImage.url)}
@@ -202,7 +203,7 @@ export function ProductCard({product, loading = 'lazy'}: ProductCardProps) {
202
203
  )}
203
204
 
204
205
  {preview ? (
205
- <img
206
+ <StoreImage
206
207
  className={`product-card__image product-card__image--preview${
207
208
  preview.visible ? ' is-visible' : ''
208
209
  }`}
@@ -279,7 +280,7 @@ export function ProductCard({product, loading = 'lazy'}: ProductCardProps) {
279
280
  onMouseOver={onSwatchEnter(cardMediaUrl)}
280
281
  aria-label={`${product.title} – ${value.name}`}
281
282
  >
282
- <img
283
+ <StoreImage
283
284
  src={imageUrl(targetVariant.image.url, 80)}
284
285
  loading="lazy"
285
286
  alt={targetVariant.image.altText ?? ''}
@@ -0,0 +1,6 @@
1
+ import type {ImgHTMLAttributes} from 'react';
2
+ import {useAdapterImage} from '../adapter';
3
+ export function StoreImage(props: ImgHTMLAttributes<HTMLImageElement>) {
4
+ const Image = useAdapterImage();
5
+ return Image ? <Image {...props} /> : <img loading="lazy" decoding="async" {...props} />;
6
+ }
@@ -1,3 +1,4 @@
1
+ import {StoreImage} from './StoreImage';
1
2
  /**
2
3
  * Port of snippets/video-modal.liquid — expanded video player dialog
3
4
  * shared by the video carousel cards and the video bubble. Built on a
@@ -196,7 +197,7 @@ export function VideoModal({
196
197
  <source src={mp4.url} type={mp4.mime_type} />
197
198
  </video>
198
199
  ) : poster ? (
199
- <img
200
+ <StoreImage
200
201
  className="video-modal__video"
201
202
  src={imageUrl(poster, 1100)}
202
203
  loading="lazy"
@@ -210,7 +211,7 @@ export function VideoModal({
210
211
  {product ? (
211
212
  <a className="video-modal__product" href={product.url}>
212
213
  {productImage ? (
213
- <img
214
+ <StoreImage
214
215
  className="video-modal__product-image"
215
216
  src={imageUrl(productImage, 120)}
216
217
  loading="lazy"