@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.
- package/dist/commerce/disclosure-motion.d.ts +2 -0
- package/dist/commerce/disclosure-motion.js +69 -0
- package/dist/commerce/image-url.d.ts +2 -0
- package/dist/commerce/image-url.js +15 -0
- package/dist/commerce/index.d.ts +1 -0
- package/dist/commerce/index.js +1 -0
- package/dist/editor/bootstrap.d.ts +15 -0
- package/dist/editor/bootstrap.js +42 -0
- package/dist/editor/draft.d.ts +17 -0
- package/dist/editor/draft.js +32 -0
- package/dist/editor/frame.d.ts +2 -0
- package/dist/editor/frame.js +7 -4
- package/dist/react/adapter.d.ts +3 -0
- package/dist/react/adapter.js +1 -0
- package/dist/react/blocks/_slide.js +2 -1
- package/dist/react/blocks/_video-card.js +2 -1
- package/dist/react/blocks/image.js +2 -1
- package/dist/react/blocks/story.js +2 -1
- package/dist/react/blocks/video.js +2 -1
- package/dist/react/components/ProductCard.js +4 -3
- package/dist/react/components/StoreImage.d.ts +2 -0
- package/dist/react/components/StoreImage.js +6 -0
- package/dist/react/components/VideoModal.js +2 -1
- package/dist/react/engine/CssVariables.js +5 -4
- package/dist/react/engine/images.d.ts +1 -1
- package/dist/react/engine/images.js +2 -6
- package/dist/react/engine/render.js +3 -1
- package/dist/react/engine/store.d.ts +6 -0
- package/dist/react/engine/store.js +34 -3
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +1 -0
- package/dist/react/sections/article.js +2 -1
- package/dist/react/sections/custom-section.js +2 -1
- package/dist/react/sections/image-with-text.js +2 -1
- package/dist/react/sections/video-bubble.js +2 -1
- package/dist/react/useEditorTemplate.d.ts +15 -0
- package/dist/react/useEditorTemplate.js +114 -0
- package/dist/schemas/bridge.d.ts +1 -0
- package/package.json +1 -1
- package/src/commerce/disclosure-motion.ts +60 -0
- package/src/commerce/image-url.ts +11 -0
- package/src/commerce/index.ts +2 -0
- package/src/editor/bootstrap.ts +61 -0
- package/src/editor/draft.ts +54 -0
- package/src/editor/frame.ts +7 -3
- package/src/react/adapter.tsx +4 -0
- package/src/react/blocks/_slide.tsx +2 -1
- package/src/react/blocks/_video-card.tsx +3 -2
- package/src/react/blocks/image.tsx +2 -1
- package/src/react/blocks/story.tsx +2 -1
- package/src/react/blocks/video.tsx +2 -1
- package/src/react/components/ProductCard.tsx +6 -5
- package/src/react/components/StoreImage.tsx +6 -0
- package/src/react/components/VideoModal.tsx +3 -2
- package/src/react/engine/CssVariables.tsx +34 -10
- package/src/react/engine/images.tsx +2 -5
- package/src/react/engine/render.tsx +14 -1
- package/src/react/engine/store.ts +43 -4
- package/src/react/index.ts +2 -0
- package/src/react/sections/article.tsx +2 -1
- package/src/react/sections/custom-section.tsx +2 -1
- package/src/react/sections/image-with-text.tsx +2 -1
- package/src/react/sections/video-bubble.tsx +2 -1
- package/src/react/useEditorTemplate.ts +136 -0
- 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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/dist/schemas/bridge.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zalify/storefront-kit",
|
|
3
|
-
"version": "0.
|
|
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
|
+
|
package/src/commerce/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/editor/frame.ts
CHANGED
|
@@ -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
|
-
//
|
|
326
|
-
//
|
|
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
|
},
|
package/src/react/adapter.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 <
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
214
|
+
<StoreImage
|
|
214
215
|
className="video-modal__product-image"
|
|
215
216
|
src={imageUrl(productImage, 120)}
|
|
216
217
|
loading="lazy"
|