@zalify/storefront-kit 0.1.11 → 0.1.13
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/frame.d.ts +2 -0
- package/dist/editor/frame.js +13 -2
- 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/images.d.ts +1 -1
- package/dist/react/engine/images.js +2 -6
- 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 +9 -0
- package/dist/react/useEditorTemplate.js +53 -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/frame.ts +13 -2
- 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/images.tsx +2 -5
- 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 +66 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Progressive native disclosure motion. Returns listener/animation cleanup. */
|
|
2
|
+
export function initDisclosureMotion() {
|
|
3
|
+
const pointerMode = () => { document.documentElement.dataset.inputMode = 'pointer'; };
|
|
4
|
+
const keyboardMode = (event) => { if (event.key === 'Tab' || event.key.startsWith('Arrow'))
|
|
5
|
+
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();
|
|
10
|
+
const toggle = (details, expanded) => {
|
|
11
|
+
const from = details.getBoundingClientRect().height;
|
|
12
|
+
running.get(details)?.cleanup();
|
|
13
|
+
const summary = details.querySelector('summary');
|
|
14
|
+
if (!summary)
|
|
15
|
+
return;
|
|
16
|
+
const originalHeight = details.style.height;
|
|
17
|
+
const originalOverflow = details.style.overflow;
|
|
18
|
+
const name = details.getAttribute('name');
|
|
19
|
+
// Hold named siblings open only long enough to finish their closing motion.
|
|
20
|
+
if (!expanded && name)
|
|
21
|
+
details.removeAttribute('name');
|
|
22
|
+
details.toggleAttribute('data-disclosure-closing', !expanded);
|
|
23
|
+
details.open = true;
|
|
24
|
+
const to = expanded ? details.getBoundingClientRect().height : summary.getBoundingClientRect().height +
|
|
25
|
+
parseFloat(getComputedStyle(details).paddingTop || '0') + parseFloat(getComputedStyle(details).paddingBottom || '0') +
|
|
26
|
+
parseFloat(getComputedStyle(details).borderTopWidth || '0') + parseFloat(getComputedStyle(details).borderBottomWidth || '0');
|
|
27
|
+
details.style.overflow = 'hidden';
|
|
28
|
+
const animation = details.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: 280, easing: 'cubic-bezier(.22, 1, .36, 1)' });
|
|
29
|
+
const cleanup = () => {
|
|
30
|
+
animation.onfinish = null;
|
|
31
|
+
animation.cancel();
|
|
32
|
+
details.removeAttribute('data-disclosure-closing');
|
|
33
|
+
details.style.height = originalHeight;
|
|
34
|
+
details.style.overflow = originalOverflow;
|
|
35
|
+
if (name)
|
|
36
|
+
details.setAttribute('name', name);
|
|
37
|
+
running.delete(details);
|
|
38
|
+
};
|
|
39
|
+
running.set(details, { animation, expanded, cleanup });
|
|
40
|
+
animation.onfinish = () => { details.open = expanded; cleanup(); };
|
|
41
|
+
};
|
|
42
|
+
const onClick = (event) => {
|
|
43
|
+
if (event.defaultPrevented || reduced.matches || event.button !== 0)
|
|
44
|
+
return;
|
|
45
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
46
|
+
const summary = target?.closest('summary');
|
|
47
|
+
const details = summary?.parentElement;
|
|
48
|
+
if (!(details instanceof HTMLDetailsElement) || !summary || getComputedStyle(summary).display === 'none')
|
|
49
|
+
return;
|
|
50
|
+
if (target?.closest('a, button, input, select, textarea'))
|
|
51
|
+
return;
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
const expanded = !(running.get(details)?.expanded ?? details.open);
|
|
54
|
+
if (expanded && details.name) {
|
|
55
|
+
document.querySelectorAll('details[open]').forEach(peer => {
|
|
56
|
+
if (peer !== details && peer.name === details.name)
|
|
57
|
+
toggle(peer, false);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
toggle(details, expanded);
|
|
61
|
+
};
|
|
62
|
+
const stop = () => { for (const [details, state] of [...running]) {
|
|
63
|
+
details.open = state.expanded;
|
|
64
|
+
state.cleanup();
|
|
65
|
+
} };
|
|
66
|
+
document.addEventListener('click', onClick);
|
|
67
|
+
reduced.addEventListener('change', stop);
|
|
68
|
+
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(); };
|
|
69
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Replace Shopify width parameters while preserving version hashes and crops. */
|
|
2
|
+
export function imageUrl(url, width) {
|
|
3
|
+
if (!width || !url.includes('cdn.shopify.com'))
|
|
4
|
+
return url;
|
|
5
|
+
try {
|
|
6
|
+
const parsed = new URL(url.startsWith('//') ? `https:${url}` : url);
|
|
7
|
+
if (parsed.hostname !== 'cdn.shopify.com')
|
|
8
|
+
return url;
|
|
9
|
+
parsed.searchParams.set('width', String(width));
|
|
10
|
+
return parsed.toString();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return url;
|
|
14
|
+
}
|
|
15
|
+
}
|
package/dist/commerce/index.d.ts
CHANGED
package/dist/commerce/index.js
CHANGED
package/dist/editor/frame.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface FrameBridgeOptions {
|
|
|
39
39
|
groups?: Record<string, SectionGroupData>;
|
|
40
40
|
settingsData?: SettingsData;
|
|
41
41
|
}) => boolean | Promise<boolean>;
|
|
42
|
+
/** Optional trusted editor origin, checked before the initial handshake. */
|
|
43
|
+
editorOrigin?: string;
|
|
42
44
|
onDeviceChange?: (device: Device) => void;
|
|
43
45
|
/** Overrides for tests / non-browser hosts. */
|
|
44
46
|
window?: Window & typeof globalThis;
|
package/dist/editor/frame.js
CHANGED
|
@@ -136,6 +136,10 @@ export function mountFrameBridge(options) {
|
|
|
136
136
|
post({ type: 'block:rects', payload: { rects } });
|
|
137
137
|
};
|
|
138
138
|
const handleMessage = async (event) => {
|
|
139
|
+
if (event.source !== win.parent)
|
|
140
|
+
return;
|
|
141
|
+
if (options.editorOrigin && event.origin !== options.editorOrigin)
|
|
142
|
+
return;
|
|
139
143
|
const data = event.data;
|
|
140
144
|
if (!isBridgeMessage(data))
|
|
141
145
|
return;
|
|
@@ -161,6 +165,13 @@ export function mountFrameBridge(options) {
|
|
|
161
165
|
}
|
|
162
166
|
options.onDeviceChange?.(message.payload.device);
|
|
163
167
|
setSelection(message.payload.selectedPath);
|
|
168
|
+
// The first measurement can run before bridge:init arrives. It is
|
|
169
|
+
// intentionally not posted until the editor origin is pinned, so
|
|
170
|
+
// invalidate the cached value and publish it now.
|
|
171
|
+
queueMicrotask(() => {
|
|
172
|
+
lastHeight = 0;
|
|
173
|
+
measure();
|
|
174
|
+
});
|
|
164
175
|
break;
|
|
165
176
|
}
|
|
166
177
|
case 'block:select':
|
|
@@ -285,7 +296,7 @@ export function mountFrameBridge(options) {
|
|
|
285
296
|
});
|
|
286
297
|
};
|
|
287
298
|
})();
|
|
288
|
-
|
|
299
|
+
function measure() {
|
|
289
300
|
// documentElement.scrollHeight never drops below the viewport, so a
|
|
290
301
|
// full-height host iframe would ratchet upward forever; the body's
|
|
291
302
|
// border box tracks actual content in both directions.
|
|
@@ -296,7 +307,7 @@ export function mountFrameBridge(options) {
|
|
|
296
307
|
lastHeight = height;
|
|
297
308
|
syncSelectionHighlight();
|
|
298
309
|
post({ type: 'height:changed', payload: { height } });
|
|
299
|
-
}
|
|
310
|
+
}
|
|
300
311
|
const resizeObserver = new win.ResizeObserver(measure);
|
|
301
312
|
resizeObserver.observe(doc.documentElement);
|
|
302
313
|
if (doc.body)
|
package/dist/react/adapter.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface CartAddFormProps {
|
|
|
23
23
|
}) => ReactNode;
|
|
24
24
|
}
|
|
25
25
|
export interface ThemeAdapter {
|
|
26
|
+
/** Optional responsive image renderer; hosts without one retain native images. */
|
|
27
|
+
Image?: ComponentType<React.ImgHTMLAttributes<HTMLImageElement>>;
|
|
26
28
|
/** Client-side navigation link. */
|
|
27
29
|
Link: ComponentType<AdapterLinkProps>;
|
|
28
30
|
/** Hook returning an imperative navigate(url, opts) function. */
|
|
@@ -47,3 +49,4 @@ export declare function AdapterProvider({ adapter, children, }: {
|
|
|
47
49
|
children: ReactNode;
|
|
48
50
|
}): import("react").JSX.Element;
|
|
49
51
|
export declare function useAdapter(): ThemeAdapter;
|
|
52
|
+
export declare function useAdapterImage(): ComponentType<import("react").ImgHTMLAttributes<HTMLImageElement>> | undefined;
|
package/dist/react/adapter.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { StoreImage } from '../components/StoreImage';
|
|
2
3
|
import { imageUrl, PlaceholderSvg, settingImageUrl } from '../engine/images';
|
|
3
4
|
function bestMp4(video) {
|
|
4
5
|
const mp4s = (video?.sources ?? []).filter((s) => s.format === 'mp4');
|
|
@@ -15,7 +16,7 @@ export default function SlideBlock({ settings, index, children, }) {
|
|
|
15
16
|
const poster = image ?? mobileImage ?? settingImageUrl(settings.video?.preview_image?.src);
|
|
16
17
|
return (_jsxs("div", { className: "hero__slide", children: [desktopMp4 || mobileMp4 ? (_jsxs("video", { className: "hero__media", autoPlay: true, loop: true, muted: true, playsInline: true, preload: "metadata", poster: poster ? imageUrl(poster, 1800) : undefined, children: [mobileMp4 && (_jsx("source", { media: "(max-width: 47.9375rem)", src: mobileMp4.url, type: mobileMp4.mime_type })), desktopMp4 && (_jsx("source", { src: desktopMp4.url, type: desktopMp4.mime_type }))] })) : image ? (_jsxs("picture", { children: [mobileImage && (_jsx("source", { media: "(max-width: 47.9375rem)", srcSet: [400, 750, 1100, 1500]
|
|
17
18
|
.map((w) => `${imageUrl(mobileImage, w)} ${w}w`)
|
|
18
|
-
.join(', '), sizes: "100vw" })), _jsx(
|
|
19
|
+
.join(', '), sizes: "100vw" })), _jsx(StoreImage, { className: "hero__media", src: imageUrl(image, 3000), srcSet: [750, 1100, 1500, 2200, 3000]
|
|
19
20
|
.map((w) => `${imageUrl(image, w)} ${w}w`)
|
|
20
21
|
.join(', '), sizes: "100vw", loading: isFirst ? 'eager' : 'lazy', fetchPriority: isFirst ? 'high' : 'auto', alt: "" })] })) : (_jsx(PlaceholderSvg, { className: "hero__media hero__media--placeholder" })), settings.link ? (_jsx("a", { className: "hero__media-link", href: settings.link, "aria-label": "View" })) : null, _jsx("div", { className: "hero__content", children: children })] }));
|
|
21
22
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { StoreImage } from '../components/StoreImage';
|
|
2
3
|
/**
|
|
3
4
|
* Port of blocks/_video-card.liquid — video carousel card: a muted
|
|
4
5
|
* preview loop that expands into the shared video modal (main video
|
|
@@ -35,5 +36,5 @@ export default function VideoCardBlock({ settings, }) {
|
|
|
35
36
|
const [open, setOpen] = useState(false);
|
|
36
37
|
const triggerLabel = t('general.play_video') +
|
|
37
38
|
(product?.title ? `: ${product.title}` : label ? `: ${label}` : '');
|
|
38
|
-
return (_jsxs("div", { className: "video-card", children: [_jsxs("button", { type: "button", className: "video-card__trigger", onClick: () => setOpen(true), "aria-label": triggerLabel, children: [previewMp4 ? (_jsx("video", { ref: previewRef, className: "video-card__media", autoPlay: true, loop: true, muted: true, playsInline: true, preload: "metadata", "data-video-preview": "", poster: poster ? imageUrl(poster, 800) : undefined, children: _jsx("source", { src: previewMp4.url, type: previewMp4.mime_type }) })) : poster ? (_jsx(
|
|
39
|
+
return (_jsxs("div", { className: "video-card", children: [_jsxs("button", { type: "button", className: "video-card__trigger", onClick: () => setOpen(true), "aria-label": triggerLabel, children: [previewMp4 ? (_jsx("video", { ref: previewRef, className: "video-card__media", autoPlay: true, loop: true, muted: true, playsInline: true, preload: "metadata", "data-video-preview": "", poster: poster ? imageUrl(poster, 800) : undefined, children: _jsx("source", { src: previewMp4.url, type: previewMp4.mime_type }) })) : poster ? (_jsx(StoreImage, { className: "video-card__media", src: imageUrl(poster, 800), loading: "lazy", alt: "" })) : (_jsx(PlaceholderSvg, { className: "video-card__media video-card__media--placeholder" })), _jsx("span", { className: "video-card__play", children: _jsx(Icon, { name: "icon-play" }) })] }), product ? (_jsxs("a", { className: "video-card__product", href: product.url, children: [productImage ? (_jsx(StoreImage, { className: "video-card__product-image", src: imageUrl(productImage, 96), loading: "lazy", alt: "" })) : null, _jsxs("span", { className: "video-card__product-info", children: [_jsx("span", { className: "video-card__product-title", children: product.title }), _jsx("span", { className: "video-card__product-price", children: formatMoney(product.price) })] })] })) : label ? (_jsx("span", { className: "video-card__label", children: label })) : null, _jsx(VideoModal, { open: open, onClose: () => setOpen(false), video: mainVideo, poster: poster, product: product, label: label })] }));
|
|
39
40
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx } 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 ImageBlock({ settings }) {
|
|
4
5
|
const { aspect = 'auto' } = settings;
|
|
5
6
|
const src = settingImageUrl(settings.image);
|
|
6
|
-
return (_jsx("div", { className: `image-block image-block--${aspect}`, children: src ? (_jsx(
|
|
7
|
+
return (_jsx("div", { className: `image-block image-block--${aspect}`, children: src ? (_jsx(StoreImage, { className: "image-block__image", src: imageUrl(src, 1500), srcSet: imageSrcSet(src), sizes: "(min-width: 48rem) 50vw, 100vw", loading: "lazy", alt: "" })) : (_jsx(PlaceholderSvg, { className: "image-block__image image-block__placeholder" })) }));
|
|
7
8
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { StoreImage } from '../components/StoreImage';
|
|
2
3
|
import { imageUrl, PlaceholderSvg, settingImageUrl } from '../engine/images';
|
|
3
4
|
export default function StoryBlock({ settings }) {
|
|
4
5
|
const image = settingImageUrl(settings.image);
|
|
5
6
|
const label = settings.label ?? '';
|
|
6
|
-
return (_jsxs("a", { className: "story", role: "listitem", href: settings.link || undefined, children: [_jsx("span", { className: "story__media", children: image ? (_jsx(
|
|
7
|
+
return (_jsxs("a", { className: "story", role: "listitem", href: settings.link || undefined, children: [_jsx("span", { className: "story__media", children: image ? (_jsx(StoreImage, { className: "story__image", src: imageUrl(image, 480), loading: "lazy", alt: label })) : (_jsx(PlaceholderSvg, { className: "story__image story__image--placeholder" })) }), label ? _jsx("span", { className: "story__label", children: label }) : null] }));
|
|
7
8
|
}
|
|
@@ -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 blocks/video.liquid — general video block: a Shopify-hosted
|
|
4
5
|
* file (ambient loop or click-to-play with native controls) or a
|
|
@@ -50,7 +51,7 @@ export default function VideoBlock({ settings }) {
|
|
|
50
51
|
: null;
|
|
51
52
|
return (_jsx("div", { className: `video-block video-block--${aspect}${frameClass}`, children: hostedMp4 ? (_jsx("video", { ref: autoplay ? previewRef : undefined, className: "video-block__media", playsInline: true, preload: "metadata", ...(autoplay
|
|
52
53
|
? { autoPlay: true, loop: true, muted: true, 'data-video-preview': '' }
|
|
53
|
-
: { controls: true }), poster: poster ? imageUrl(poster, 1500) : undefined, children: _jsx("source", { src: hostedMp4.url, type: hostedMp4.mime_type }) })) : embedUrl ? (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: "video-block__facade", onClick: () => setPlaying(true), "aria-label": t('general.play_video'), hidden: playing, children: [poster ? (_jsx(
|
|
54
|
+
: { controls: true }), poster: poster ? imageUrl(poster, 1500) : undefined, children: _jsx("source", { src: hostedMp4.url, type: hostedMp4.mime_type }) })) : embedUrl ? (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: "video-block__facade", onClick: () => setPlaying(true), "aria-label": t('general.play_video'), hidden: playing, children: [poster ? (_jsx(StoreImage, { className: "video-block__media", src: imageUrl(poster, 1500), srcSet: [400, 700, 1000, 1500]
|
|
54
55
|
.map((w) => `${imageUrl(poster, w)} ${w}w`)
|
|
55
56
|
.join(', '), loading: "lazy", alt: "" })) : (_jsx(PlaceholderSvg, { className: "video-block__media video-block__placeholder" })), _jsx("span", { className: "video-block__play", children: _jsx(Icon, { name: "icon-play" }) })] }), _jsx("iframe", { className: "video-block__media", src: playing ? embedUrl : undefined, title: t('general.play_video'), allow: "autoplay; fullscreen; encrypted-media; picture-in-picture", allowFullScreen: true, hidden: !playing })] })) : (_jsxs(_Fragment, { children: [_jsx(PlaceholderSvg, { className: "video-block__media video-block__placeholder" }), _jsx("span", { className: "video-block__play", children: _jsx(Icon, { name: "icon-play" }) })] })) }));
|
|
56
57
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { StoreImage } from './StoreImage';
|
|
2
3
|
/**
|
|
3
4
|
* Port of snippets/product-card.liquid — a product card for grids:
|
|
4
5
|
* square media (second image on hover), title, price, and badges
|
|
@@ -46,7 +47,7 @@ export function ProductCard({ product, loading = 'lazy' }) {
|
|
|
46
47
|
? (product.images?.nodes?.[1] ?? null)
|
|
47
48
|
: null;
|
|
48
49
|
// Swatch-hover preview, crossfading over the base image. The Liquid
|
|
49
|
-
// island creates the <
|
|
50
|
+
// island creates the <StoreImage> on first hover; React renders it once a
|
|
50
51
|
// swatch has actually been hovered.
|
|
51
52
|
const [preview, setPreview] = useState(null);
|
|
52
53
|
const onSwatchEnter = (mediaUrl) => () => {
|
|
@@ -95,7 +96,7 @@ export function ProductCard({ product, loading = 'lazy' }) {
|
|
|
95
96
|
const extraCount = pickerOption
|
|
96
97
|
? pickerOption.optionValues.length - valueLimit
|
|
97
98
|
: 0;
|
|
98
|
-
return (_jsxs("div", { className: "product-card", children: [_jsxs("div", { className: "product-card__frame", children: [_jsxs(Link, { to: url, className: "product-card__media", "aria-label": product.title, children: [featuredImage ? (_jsxs(_Fragment, { children: [_jsx(
|
|
99
|
+
return (_jsxs("div", { className: "product-card", children: [_jsxs("div", { className: "product-card__frame", children: [_jsxs(Link, { to: url, className: "product-card__media", "aria-label": product.title, children: [featuredImage ? (_jsxs(_Fragment, { children: [_jsx(StoreImage, { className: "product-card__image", src: imageUrl(featuredImage.url, 800), srcSet: cardSrcSet(featuredImage.url), sizes: CARD_SIZES, loading: loading, ...(loading === 'eager' ? { fetchpriority: 'high' } : null), alt: featuredImage.altText ?? '', width: featuredImage.width ?? undefined, height: featuredImage.height ?? undefined }), secondaryImage ? (_jsx(StoreImage, { className: "product-card__image product-card__image--hover", src: imageUrl(secondaryImage.url, 800), srcSet: cardSrcSet(secondaryImage.url), sizes: CARD_SIZES, loading: "lazy", alt: "", "aria-hidden": "true", width: secondaryImage.width ?? undefined, height: secondaryImage.height ?? undefined })) : null] })) : (_jsx(PlaceholderSvg, { className: "product-card__placeholder" })), preview ? (_jsx(StoreImage, { className: `product-card__image product-card__image--preview${preview.visible ? ' is-visible' : ''}`, src: preview.src, alt: "", "aria-hidden": "true", onLoad: () => setPreview((previous) => previous ? { ...previous, visible: true } : previous) })) : null, badgePosition === 'corner' && showBadges ? (_jsx("div", { className: "product-card__badges", children: _jsx(ProductBadges, { product: product }) })) : null] }), showQuickAdd ? (_jsx("div", { className: "product-card__quick product-card__quick--overlay", children: quickAdd })) : null] }), _jsxs("div", { className: "product-card__info stack", style: { '--stack-gap': 'var(--space-2xs)' }, children: [badgePosition === 'below' && showBadges ? (_jsx("div", { className: "product-card__badges", children: _jsx(ProductBadges, { product: product }) })) : null, _jsx("h3", { className: "product-card__title", children: _jsx(Link, { to: url, children: product.title }) }), _jsx(Price, { price: product.priceRange?.minVariantPrice, compareAt: product.compareAtPriceRange?.minVariantPrice, unitPrice: product.selectedOrFirstAvailableVariant?.unitPrice, unitPriceMeasurement: product.selectedOrFirstAvailableVariant?.unitPriceMeasurement }), themeSettings.card_show_rating ? _jsx(Rating, { product: product }) : null, pickerOption ? (_jsxs("div", { className: "product-card__swatches", onMouseOut: onSwatchLeave, children: [pickerOption.optionValues.slice(0, valueLimit).map((value) => {
|
|
99
100
|
const targetVariant = targetVariantFor(product, pickerOption, value.name);
|
|
100
101
|
const valueUrl = targetVariant
|
|
101
102
|
? `${url}?variant=${numericId(targetVariant.id)}`
|
|
@@ -106,7 +107,7 @@ export function ProductCard({ product, loading = 'lazy' }) {
|
|
|
106
107
|
if (swatchStyleSetting === 'variant_image') {
|
|
107
108
|
if (!targetVariant?.image)
|
|
108
109
|
return null;
|
|
109
|
-
return (_jsx(Link, { className: "product-card__variant-thumb", to: valueUrl, "data-card-media": cardMediaUrl, onMouseOver: onSwatchEnter(cardMediaUrl), "aria-label": `${product.title} – ${value.name}`, children: _jsx(
|
|
110
|
+
return (_jsx(Link, { className: "product-card__variant-thumb", to: valueUrl, "data-card-media": cardMediaUrl, onMouseOver: onSwatchEnter(cardMediaUrl), "aria-label": `${product.title} – ${value.name}`, children: _jsx(StoreImage, { src: imageUrl(targetVariant.image.url, 80), loading: "lazy", alt: targetVariant.image.altText ?? '' }) }, value.name));
|
|
110
111
|
}
|
|
111
112
|
return (_jsx(Link, { className: "swatch swatch--sm", to: valueUrl, "data-card-media": cardMediaUrl, onMouseOver: cardMediaUrl ? onSwatchEnter(cardMediaUrl) : undefined, style: swatchStyle(value.name, value.swatch), "aria-label": `${product.title} – ${value.name}` }, value.name));
|
|
112
113
|
}), extraCount > 0 ? (swatchStyleSetting === 'variant_image' ? (_jsxs(Link, { className: "product-card__swatch-count product-card__swatch-count--tile", to: url, "aria-label": t('products.choose_options'), children: ["+", extraCount] })) : (_jsxs("span", { className: "product-card__swatch-count", "aria-hidden": "true", children: ["+", extraCount] }))) : null] })) : null, showQuickAdd ? (_jsx("div", { className: "product-card__quick product-card__quick--inline", children: quickAdd })) : null] })] }));
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useAdapterImage } from '../adapter';
|
|
3
|
+
export function StoreImage(props) {
|
|
4
|
+
const Image = useAdapterImage();
|
|
5
|
+
return Image ? _jsx(Image, { ...props }) : _jsx("img", { loading: "lazy", decoding: "async", ...props });
|
|
6
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { StoreImage } from './StoreImage';
|
|
2
3
|
/**
|
|
3
4
|
* Port of snippets/video-modal.liquid — expanded video player dialog
|
|
4
5
|
* shared by the video carousel cards and the video bubble. Built on a
|
|
@@ -98,5 +99,5 @@ export function VideoModal({ id, open, onClose, video, poster, product, label, }
|
|
|
98
99
|
onClick: (event) => {
|
|
99
100
|
if (event.target === dialogRef.current)
|
|
100
101
|
dialogRef.current?.close();
|
|
101
|
-
}, children: [_jsx("button", { type: "button", className: "video-modal__close", onClick: () => dialogRef.current?.close(), "aria-label": t('general.close'), children: _jsx(Icon, { name: "icon-close" }) }), _jsx("div", { className: "video-modal__media", children: mp4 ? (_jsx("video", { ref: videoRef, className: "video-modal__video", controls: true, playsInline: true, preload: "metadata", poster: poster ? imageUrl(poster, 1100) : undefined, children: _jsx("source", { src: mp4.url, type: mp4.mime_type }) })) : poster ? (_jsx(
|
|
102
|
+
}, children: [_jsx("button", { type: "button", className: "video-modal__close", onClick: () => dialogRef.current?.close(), "aria-label": t('general.close'), children: _jsx(Icon, { name: "icon-close" }) }), _jsx("div", { className: "video-modal__media", children: mp4 ? (_jsx("video", { ref: videoRef, className: "video-modal__video", controls: true, playsInline: true, preload: "metadata", poster: poster ? imageUrl(poster, 1100) : undefined, children: _jsx("source", { src: mp4.url, type: mp4.mime_type }) })) : poster ? (_jsx(StoreImage, { className: "video-modal__video", src: imageUrl(poster, 1100), loading: "lazy", alt: "" })) : (_jsx(PlaceholderSvg, { className: "video-modal__video" })) }), product ? (_jsxs("a", { className: "video-modal__product", href: product.url, children: [productImage ? (_jsx(StoreImage, { className: "video-modal__product-image", src: imageUrl(productImage, 120), loading: "lazy", alt: "" })) : null, _jsxs("span", { className: "video-modal__product-info", children: [_jsx("span", { className: "video-modal__product-title", children: product.title }), _jsx("span", { className: "video-modal__product-price", children: _jsx(Price, { price: product.price, compareAt: product.compare_at_price }) })] }), _jsx("span", { className: "video-modal__product-cta", children: t('products.view_product') })] })) : null] }));
|
|
102
103
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export declare function settingImageUrl(value: unknown): string | null;
|
|
14
14
|
/** Append a width to a Shopify CDN URL (mirror of `| image_url: width`). */
|
|
15
|
-
export
|
|
15
|
+
export { imageUrl } from '../../commerce/image-url';
|
|
16
16
|
/** Srcset across the theme's standard width ladder. */
|
|
17
17
|
export declare function imageSrcSet(url: string, widths?: number[]): string | undefined;
|
|
18
18
|
/**
|
|
@@ -19,12 +19,8 @@ export function settingImageUrl(value) {
|
|
|
19
19
|
return value;
|
|
20
20
|
}
|
|
21
21
|
/** Append a width to a Shopify CDN URL (mirror of `| image_url: width`). */
|
|
22
|
-
export
|
|
23
|
-
|
|
24
|
-
return url;
|
|
25
|
-
const separator = url.includes('?') ? '&' : '?';
|
|
26
|
-
return `${url}${separator}width=${width}`;
|
|
27
|
-
}
|
|
22
|
+
export { imageUrl } from '../../commerce/image-url';
|
|
23
|
+
import { imageUrl } from '../../commerce/image-url';
|
|
28
24
|
/** Srcset across the theme's standard width ladder. */
|
|
29
25
|
export function imageSrcSet(url, widths = [400, 700, 1000, 1500]) {
|
|
30
26
|
if (!url.includes('cdn.shopify.com'))
|
package/dist/react/index.d.ts
CHANGED
|
@@ -36,3 +36,4 @@ export { ProductCard, PRODUCT_CARD_FRAGMENT } from './components/ProductCard';
|
|
|
36
36
|
export * from './components/VideoModal';
|
|
37
37
|
export { Facets, COLLECTION_SORT_OPTIONS, SEARCH_SORT_OPTIONS, } from './components/Facets';
|
|
38
38
|
export { builtinSections, builtinBlocks } from './registries';
|
|
39
|
+
export { useEditorTemplate } from './useEditorTemplate';
|
package/dist/react/index.js
CHANGED
|
@@ -37,3 +37,4 @@ export { Facets, COLLECTION_SORT_OPTIONS, SEARCH_SORT_OPTIONS, } from './compone
|
|
|
37
37
|
// Built-in registries (spread into installTheme with app extras).
|
|
38
38
|
// Server-only loaders live in '@zalify/storefront-kit/react/server'.
|
|
39
39
|
export { builtinSections, builtinBlocks } from './registries';
|
|
40
|
+
export { useEditorTemplate } from './useEditorTemplate';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { StoreImage } from '../components/StoreImage';
|
|
2
3
|
import { useResource } from '../engine/context';
|
|
3
4
|
import { t } from '../engine/translate';
|
|
4
5
|
import { imageSrcSet, imageUrl } from '../engine/images';
|
|
@@ -27,5 +28,5 @@ export default function ArticleSection({ settings, }) {
|
|
|
27
28
|
date: articleDateHtml(article.publishedAt),
|
|
28
29
|
author: article.author?.name ?? '',
|
|
29
30
|
}),
|
|
30
|
-
} }), _jsx("h1", { className: "article__title", children: article.title })] }), article.image?.url && (_jsx(
|
|
31
|
+
} }), _jsx("h1", { className: "article__title", children: article.title })] }), article.image?.url && (_jsx(StoreImage, { className: "article__image", src: imageUrl(article.image.url, 1500), srcSet: imageSrcSet(article.image.url), sizes: "(min-width: 64rem) 60rem, 100vw", loading: "eager", width: article.image.width ?? undefined, height: article.image.height ?? undefined, alt: article.image.altText ?? article.title ?? '' })), _jsx("div", { className: "article__content prose", dangerouslySetInnerHTML: { __html: article.contentHtml ?? '' } })] }) }));
|
|
31
32
|
}
|
|
@@ -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,9 @@
|
|
|
1
|
+
import type { TemplateData } from "../schemas/data";
|
|
2
|
+
import type { ThemeEditorManifest } from "../schemas/manifest";
|
|
3
|
+
type Options = {
|
|
4
|
+
origins: readonly string[];
|
|
5
|
+
loadManifest: () => Promise<ThemeEditorManifest>;
|
|
6
|
+
};
|
|
7
|
+
/** No editor code or schema is fetched outside an explicitly allowed preview. */
|
|
8
|
+
export declare function useEditorTemplate(name: string, options: Options): TemplateData | null;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
/** No editor code or schema is fetched outside an explicitly allowed preview. */
|
|
4
|
+
export function useEditorTemplate(name, options) {
|
|
5
|
+
const [draft, setDraft] = useState(null);
|
|
6
|
+
const controller = useRef(null);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
setDraft(null);
|
|
9
|
+
if (!options.origins.length ||
|
|
10
|
+
window.self === window.top ||
|
|
11
|
+
new URL(window.location.href).searchParams.get("zalify-editor") !== "1")
|
|
12
|
+
return;
|
|
13
|
+
let parentOrigin;
|
|
14
|
+
try {
|
|
15
|
+
parentOrigin = new URL(document.referrer).origin;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (!options.origins.includes(parentOrigin))
|
|
21
|
+
return;
|
|
22
|
+
let disposed = false;
|
|
23
|
+
void Promise.all([import("../editor/frame"), options.loadManifest()])
|
|
24
|
+
.then(([bridge, manifest]) => {
|
|
25
|
+
if (disposed)
|
|
26
|
+
return;
|
|
27
|
+
controller.current = bridge.mountFrameBridge({
|
|
28
|
+
editorOrigin: parentOrigin,
|
|
29
|
+
templateName: name,
|
|
30
|
+
hash: manifest.hash,
|
|
31
|
+
getManifest: () => manifest,
|
|
32
|
+
applyTemplate(payload) {
|
|
33
|
+
if (payload.templateName !== name)
|
|
34
|
+
return false;
|
|
35
|
+
setDraft({ name, template: payload.template });
|
|
36
|
+
return true;
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
})
|
|
40
|
+
.catch(() => {
|
|
41
|
+
/* The storefront remains usable if preview assets fail. */
|
|
42
|
+
});
|
|
43
|
+
return () => {
|
|
44
|
+
disposed = true;
|
|
45
|
+
controller.current?.unmount();
|
|
46
|
+
controller.current = null;
|
|
47
|
+
};
|
|
48
|
+
}, [name, options]);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
controller.current?.reportRects();
|
|
51
|
+
}, [draft]);
|
|
52
|
+
return draft?.name === name ? draft.template : null;
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zalify/storefront-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
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
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;
|
|
@@ -233,6 +237,13 @@ export function mountFrameBridge(
|
|
|
233
237
|
}
|
|
234
238
|
options.onDeviceChange?.(message.payload.device);
|
|
235
239
|
setSelection(message.payload.selectedPath);
|
|
240
|
+
// The first measurement can run before bridge:init arrives. It is
|
|
241
|
+
// intentionally not posted until the editor origin is pinned, so
|
|
242
|
+
// invalidate the cached value and publish it now.
|
|
243
|
+
queueMicrotask(() => {
|
|
244
|
+
lastHeight = 0;
|
|
245
|
+
measure();
|
|
246
|
+
});
|
|
236
247
|
break;
|
|
237
248
|
}
|
|
238
249
|
case 'block:select':
|
|
@@ -358,7 +369,7 @@ export function mountFrameBridge(
|
|
|
358
369
|
};
|
|
359
370
|
})();
|
|
360
371
|
|
|
361
|
-
|
|
372
|
+
function measure(): void {
|
|
362
373
|
// documentElement.scrollHeight never drops below the viewport, so a
|
|
363
374
|
// full-height host iframe would ratchet upward forever; the body's
|
|
364
375
|
// border box tracks actual content in both directions.
|
|
@@ -370,7 +381,7 @@ export function mountFrameBridge(
|
|
|
370
381
|
lastHeight = height;
|
|
371
382
|
syncSelectionHighlight();
|
|
372
383
|
post({type: 'height:changed', payload: {height}});
|
|
373
|
-
}
|
|
384
|
+
}
|
|
374
385
|
const resizeObserver = new win.ResizeObserver(measure);
|
|
375
386
|
resizeObserver.observe(doc.documentElement);
|
|
376
387
|
if (doc.body) resizeObserver.observe(doc.body);
|
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"
|
|
@@ -18,11 +18,8 @@ export function settingImageUrl(value: unknown): string | null {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/** Append a width to a Shopify CDN URL (mirror of `| image_url: width`). */
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
const separator = url.includes('?') ? '&' : '?';
|
|
24
|
-
return `${url}${separator}width=${width}`;
|
|
25
|
-
}
|
|
21
|
+
export {imageUrl} from '../../commerce/image-url';
|
|
22
|
+
import {imageUrl} from '../../commerce/image-url';
|
|
26
23
|
|
|
27
24
|
/** Srcset across the theme's standard width ladder. */
|
|
28
25
|
export function imageSrcSet(
|
package/src/react/index.ts
CHANGED
|
@@ -77,3 +77,5 @@ export {
|
|
|
77
77
|
// Built-in registries (spread into installTheme with app extras).
|
|
78
78
|
// Server-only loaders live in '@zalify/storefront-kit/react/server'.
|
|
79
79
|
export {builtinSections, builtinBlocks} from './registries';
|
|
80
|
+
|
|
81
|
+
export {useEditorTemplate} from './useEditorTemplate';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {StoreImage} from '../components/StoreImage';
|
|
1
2
|
/**
|
|
2
3
|
* Port of sections/article.liquid. CSS: app/styles/components/sections-article.css
|
|
3
4
|
*
|
|
@@ -75,7 +76,7 @@ export default function ArticleSection({
|
|
|
75
76
|
</header>
|
|
76
77
|
|
|
77
78
|
{article.image?.url && (
|
|
78
|
-
<
|
|
79
|
+
<StoreImage
|
|
79
80
|
className="article__image"
|
|
80
81
|
src={imageUrl(article.image.url, 1500)}
|
|
81
82
|
srcSet={imageSrcSet(article.image.url)}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {StoreImage} from '../components/StoreImage';
|
|
1
2
|
/**
|
|
2
3
|
* Port of sections/custom-section.liquid — the canonical v3 section
|
|
3
4
|
* pattern: color_scheme + section_spacing on the wrapper, optional
|
|
@@ -34,7 +35,7 @@ export default function CustomSection({
|
|
|
34
35
|
>
|
|
35
36
|
{backgroundImage ? (
|
|
36
37
|
<div className="custom-section__background">
|
|
37
|
-
<
|
|
38
|
+
<StoreImage src={imageUrl(backgroundImage, 2000)} alt="" />
|
|
38
39
|
</div>
|
|
39
40
|
) : null}
|
|
40
41
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {StoreImage} from '../components/StoreImage';
|
|
1
2
|
/**
|
|
2
3
|
* Port of sections/image-with-text.liquid — media column + block stack.
|
|
3
4
|
* CSS: app/styles/components/sections-image-with-text.css
|
|
@@ -42,7 +43,7 @@ export default function ImageWithTextSection({
|
|
|
42
43
|
>
|
|
43
44
|
<div className="image-with-text__media">
|
|
44
45
|
{image ? (
|
|
45
|
-
<
|
|
46
|
+
<StoreImage
|
|
46
47
|
className="image-with-text__image"
|
|
47
48
|
src={imageUrl(image, 1500)}
|
|
48
49
|
srcSet={imageSrcSet(image, [400, 700, 1000, 1500])}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {StoreImage} from '../components/StoreImage';
|
|
1
2
|
/**
|
|
2
3
|
* Port of sections/video-bubble.liquid — floating video bubble: a small
|
|
3
4
|
* muted preview loop pinned to a bottom corner that expands into the
|
|
@@ -107,7 +108,7 @@ export default function VideoBubbleSection({
|
|
|
107
108
|
<source src={previewMp4.url} type={previewMp4.mime_type} />
|
|
108
109
|
</video>
|
|
109
110
|
) : poster ? (
|
|
110
|
-
<
|
|
111
|
+
<StoreImage
|
|
111
112
|
className="video-bubble__media"
|
|
112
113
|
src={imageUrl(poster, 400)}
|
|
113
114
|
loading="lazy"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import type { TemplateData } from "../schemas/data";
|
|
4
|
+
import type { ThemeEditorManifest } from "../schemas/manifest";
|
|
5
|
+
import type { FrameBridgeController } from "../editor/frame";
|
|
6
|
+
|
|
7
|
+
type Options = {
|
|
8
|
+
origins: readonly string[];
|
|
9
|
+
loadManifest: () => Promise<ThemeEditorManifest>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** No editor code or schema is fetched outside an explicitly allowed preview. */
|
|
13
|
+
export function useEditorTemplate(
|
|
14
|
+
name: string,
|
|
15
|
+
options: Options,
|
|
16
|
+
): TemplateData | null {
|
|
17
|
+
const [draft, setDraft] = useState<{
|
|
18
|
+
name: string;
|
|
19
|
+
template: TemplateData;
|
|
20
|
+
} | null>(null);
|
|
21
|
+
const controller = useRef<FrameBridgeController | null>(null);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
setDraft(null);
|
|
24
|
+
if (
|
|
25
|
+
!options.origins.length ||
|
|
26
|
+
window.self === window.top ||
|
|
27
|
+
new URL(window.location.href).searchParams.get("zalify-editor") !== "1"
|
|
28
|
+
)
|
|
29
|
+
return;
|
|
30
|
+
let parentOrigin: string;
|
|
31
|
+
try {
|
|
32
|
+
parentOrigin = new URL(document.referrer).origin;
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (!options.origins.includes(parentOrigin)) return;
|
|
37
|
+
let disposed = false;
|
|
38
|
+
void Promise.all([import("../editor/frame"), options.loadManifest()])
|
|
39
|
+
.then(([bridge, manifest]) => {
|
|
40
|
+
if (disposed) return;
|
|
41
|
+
controller.current = bridge.mountFrameBridge({
|
|
42
|
+
editorOrigin: parentOrigin,
|
|
43
|
+
templateName: name,
|
|
44
|
+
hash: manifest.hash,
|
|
45
|
+
getManifest: () => manifest,
|
|
46
|
+
applyTemplate(payload) {
|
|
47
|
+
if (payload.templateName !== name) return false;
|
|
48
|
+
setDraft({ name, template: payload.template });
|
|
49
|
+
return true;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
})
|
|
53
|
+
.catch(() => {
|
|
54
|
+
/* The storefront remains usable if preview assets fail. */
|
|
55
|
+
});
|
|
56
|
+
return () => {
|
|
57
|
+
disposed = true;
|
|
58
|
+
controller.current?.unmount();
|
|
59
|
+
controller.current = null;
|
|
60
|
+
};
|
|
61
|
+
}, [name, options]);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
controller.current?.reportRects();
|
|
64
|
+
}, [draft]);
|
|
65
|
+
return draft?.name === name ? draft.template : null;
|
|
66
|
+
}
|