@zalify/storefront-kit 0.1.12 → 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.
Files changed (50) hide show
  1. package/dist/commerce/disclosure-motion.d.ts +2 -0
  2. package/dist/commerce/disclosure-motion.js +69 -0
  3. package/dist/commerce/image-url.d.ts +2 -0
  4. package/dist/commerce/image-url.js +15 -0
  5. package/dist/commerce/index.d.ts +1 -0
  6. package/dist/commerce/index.js +1 -0
  7. package/dist/editor/frame.d.ts +2 -0
  8. package/dist/editor/frame.js +4 -0
  9. package/dist/react/adapter.d.ts +3 -0
  10. package/dist/react/adapter.js +1 -0
  11. package/dist/react/blocks/_slide.js +2 -1
  12. package/dist/react/blocks/_video-card.js +2 -1
  13. package/dist/react/blocks/image.js +2 -1
  14. package/dist/react/blocks/story.js +2 -1
  15. package/dist/react/blocks/video.js +2 -1
  16. package/dist/react/components/ProductCard.js +4 -3
  17. package/dist/react/components/StoreImage.d.ts +2 -0
  18. package/dist/react/components/StoreImage.js +6 -0
  19. package/dist/react/components/VideoModal.js +2 -1
  20. package/dist/react/engine/images.d.ts +1 -1
  21. package/dist/react/engine/images.js +2 -6
  22. package/dist/react/index.d.ts +1 -0
  23. package/dist/react/index.js +1 -0
  24. package/dist/react/sections/article.js +2 -1
  25. package/dist/react/sections/custom-section.js +2 -1
  26. package/dist/react/sections/image-with-text.js +2 -1
  27. package/dist/react/sections/video-bubble.js +2 -1
  28. package/dist/react/useEditorTemplate.d.ts +9 -0
  29. package/dist/react/useEditorTemplate.js +53 -0
  30. package/package.json +1 -1
  31. package/src/commerce/disclosure-motion.ts +60 -0
  32. package/src/commerce/image-url.ts +11 -0
  33. package/src/commerce/index.ts +2 -0
  34. package/src/editor/frame.ts +4 -0
  35. package/src/react/adapter.tsx +4 -0
  36. package/src/react/blocks/_slide.tsx +2 -1
  37. package/src/react/blocks/_video-card.tsx +3 -2
  38. package/src/react/blocks/image.tsx +2 -1
  39. package/src/react/blocks/story.tsx +2 -1
  40. package/src/react/blocks/video.tsx +2 -1
  41. package/src/react/components/ProductCard.tsx +6 -5
  42. package/src/react/components/StoreImage.tsx +6 -0
  43. package/src/react/components/VideoModal.tsx +3 -2
  44. package/src/react/engine/images.tsx +2 -5
  45. package/src/react/index.ts +2 -0
  46. package/src/react/sections/article.tsx +2 -1
  47. package/src/react/sections/custom-section.tsx +2 -1
  48. package/src/react/sections/image-with-text.tsx +2 -1
  49. package/src/react/sections/video-bubble.tsx +2 -1
  50. package/src/react/useEditorTemplate.ts +66 -0
@@ -0,0 +1,2 @@
1
+ /** Progressive native disclosure motion. Returns listener/animation cleanup. */
2
+ export declare function initDisclosureMotion(): () => void;
@@ -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,2 @@
1
+ /** Replace Shopify width parameters while preserving version hashes and crops. */
2
+ export declare function imageUrl(url: string, width?: number): string;
@@ -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
+ }
@@ -10,3 +10,4 @@ export * from './countdown';
10
10
  export * from './facets';
11
11
  export * from './product';
12
12
  export * from './google-fonts';
13
+ export { initDisclosureMotion } from './disclosure-motion';
@@ -10,3 +10,4 @@ export * from './countdown';
10
10
  export * from './facets';
11
11
  export * from './product';
12
12
  export * from './google-fonts';
13
+ export { initDisclosureMotion } from './disclosure-motion';
@@ -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;
@@ -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;
@@ -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;
@@ -21,3 +21,4 @@ export function useAdapter() {
21
21
  }
22
22
  return adapter;
23
23
  }
24
+ export function useAdapterImage() { return useContext(AdapterContext)?.Image; }
@@ -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("img", { className: "hero__media", src: imageUrl(image, 3000), srcSet: [750, 1100, 1500, 2200, 3000]
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("img", { 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("img", { 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
+ 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("img", { 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
+ 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("img", { 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
+ 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("img", { className: "video-block__media", src: imageUrl(poster, 1500), srcSet: [400, 700, 1000, 1500]
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 <img> on first hover; React renders it once a
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("img", { 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("img", { 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("img", { 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
+ 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("img", { src: imageUrl(targetVariant.image.url, 80), loading: "lazy", alt: targetVariant.image.altText ?? '' }) }, value.name));
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,2 @@
1
+ import type { ImgHTMLAttributes } from 'react';
2
+ export declare function StoreImage(props: ImgHTMLAttributes<HTMLImageElement>): import("react").JSX.Element;
@@ -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("img", { 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("img", { 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
+ }, 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 declare function imageUrl(url: string, width?: number): string;
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 function imageUrl(url, width) {
23
- if (!width || !url.includes('cdn.shopify.com'))
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'))
@@ -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';
@@ -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("img", { 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
+ } }), _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("img", { src: imageUrl(backgroundImage, 2000), alt: "" }) })) : null, _jsx("div", { className: "custom-section__content", children: children })] }));
9
+ }, children: [backgroundImage ? (_jsx("div", { className: "custom-section__background", children: _jsx(StoreImage, { src: imageUrl(backgroundImage, 2000), alt: "" }) })) : null, _jsx("div", { className: "custom-section__content", children: children })] }));
9
10
  }
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { StoreImage } from '../components/StoreImage';
2
3
  import { imageSrcSet, imageUrl, PlaceholderSvg, settingImageUrl, } from '../engine/images';
3
4
  export default function ImageWithTextSection({ settings, children, }) {
4
5
  const { image_position: imagePosition = 'left', color_scheme: colorScheme = 'scheme-1', section_spacing: sectionSpacing = 'lg', } = settings;
5
6
  const image = settingImageUrl(settings.image);
6
7
  return (_jsx("div", { className: `image-with-text section color-${colorScheme} full-width`, style: {
7
8
  '--section-spacing': `var(--space-section-${sectionSpacing})`,
8
- }, children: _jsxs("div", { className: `image-with-text__inner image-with-text--media-${imagePosition}`, children: [_jsx("div", { className: "image-with-text__media", children: image ? (_jsx("img", { className: "image-with-text__image", src: imageUrl(image, 1500), srcSet: imageSrcSet(image, [400, 700, 1000, 1500]), sizes: "(min-width: 48rem) 50vw, 100vw", loading: "lazy", alt: "" })) : (_jsx(PlaceholderSvg, { className: "image-with-text__image image-with-text__placeholder" })) }), _jsx("div", { className: "image-with-text__content stack", children: children })] }) }));
9
+ }, children: _jsxs("div", { className: `image-with-text__inner image-with-text--media-${imagePosition}`, children: [_jsx("div", { className: "image-with-text__media", children: image ? (_jsx(StoreImage, { className: "image-with-text__image", src: imageUrl(image, 1500), srcSet: imageSrcSet(image, [400, 700, 1000, 1500]), sizes: "(min-width: 48rem) 50vw, 100vw", loading: "lazy", alt: "" })) : (_jsx(PlaceholderSvg, { className: "image-with-text__image image-with-text__placeholder" })) }), _jsx("div", { className: "image-with-text__content stack", children: children })] }) }));
9
10
  }
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { StoreImage } from '../components/StoreImage';
2
3
  /**
3
4
  * Port of sections/video-bubble.liquid — floating video bubble: a small
4
5
  * muted preview loop pinned to a bottom corner that expands into the
@@ -48,7 +49,7 @@ export default function VideoBubbleSection({ id, settings, }) {
48
49
  // Storage can be unavailable (private mode); hiding is enough
49
50
  }
50
51
  };
51
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: `video-bubble video-bubble--${position} video-bubble--${shape}`, style: { '--bubble-size': `${size}px` }, "data-dismiss-key": id, children: [_jsx("button", { type: "button", className: "video-bubble__dismiss", onClick: dismiss, "aria-label": t('general.close'), children: _jsx(Icon, { name: "icon-close" }) }), _jsxs("button", { type: "button", className: "video-bubble__trigger", onClick: () => setOpen(true), "aria-label": t('general.play_video'), children: [previewMp4 ? (_jsx("video", { ref: previewRef, className: "video-bubble__media", autoPlay: true, loop: true, muted: true, playsInline: true, preload: "metadata", "data-video-preview": "", poster: poster ? imageUrl(poster, 400) : undefined, children: _jsx("source", { src: previewMp4.url, type: previewMp4.mime_type }) })) : poster ? (_jsx("img", { className: "video-bubble__media", src: imageUrl(poster, 400), loading: "lazy", alt: "" })) : (_jsx(PlaceholderSvg, { className: "video-bubble__media video-bubble__media--placeholder" })), _jsx("span", { className: "video-bubble__play", children: _jsx(Icon, { name: "icon-play" }) })] }), _jsx(VideoModal, { open: open, onClose: () => setOpen(false), video: mainVideo, poster: poster, product: product })] }), _jsx("script", { dangerouslySetInnerHTML: {
52
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: `video-bubble video-bubble--${position} video-bubble--${shape}`, style: { '--bubble-size': `${size}px` }, "data-dismiss-key": id, children: [_jsx("button", { type: "button", className: "video-bubble__dismiss", onClick: dismiss, "aria-label": t('general.close'), children: _jsx(Icon, { name: "icon-close" }) }), _jsxs("button", { type: "button", className: "video-bubble__trigger", onClick: () => setOpen(true), "aria-label": t('general.play_video'), children: [previewMp4 ? (_jsx("video", { ref: previewRef, className: "video-bubble__media", autoPlay: true, loop: true, muted: true, playsInline: true, preload: "metadata", "data-video-preview": "", poster: poster ? imageUrl(poster, 400) : undefined, children: _jsx("source", { src: previewMp4.url, type: previewMp4.mime_type }) })) : poster ? (_jsx(StoreImage, { className: "video-bubble__media", src: imageUrl(poster, 400), loading: "lazy", alt: "" })) : (_jsx(PlaceholderSvg, { className: "video-bubble__media video-bubble__media--placeholder" })), _jsx("span", { className: "video-bubble__play", children: _jsx(Icon, { name: "icon-play" }) })] }), _jsx(VideoModal, { open: open, onClose: () => setOpen(false), video: mainVideo, poster: poster, product: product })] }), _jsx("script", { dangerouslySetInnerHTML: {
52
53
  __html: `(function(){try{if(sessionStorage.getItem(${JSON.stringify(dismissKey)})){document.currentScript.previousElementSibling.hidden=true;}}catch(e){}})();`,
53
54
  } })] }));
54
55
  }
@@ -0,0 +1,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.12",
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
+
@@ -10,3 +10,5 @@ export * from './countdown';
10
10
  export * from './facets';
11
11
  export * from './product';
12
12
  export * from './google-fonts';
13
+
14
+ export {initDisclosureMotion} from './disclosure-motion';
@@ -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;
@@ -36,6 +36,8 @@ export interface CartAddFormProps {
36
36
  }
37
37
 
38
38
  export interface ThemeAdapter {
39
+ /** Optional responsive image renderer; hosts without one retain native images. */
40
+ Image?: ComponentType<React.ImgHTMLAttributes<HTMLImageElement>>;
39
41
  /** Client-side navigation link. */
40
42
  Link: ComponentType<AdapterLinkProps>;
41
43
  /** Hook returning an imperative navigate(url, opts) function. */
@@ -81,3 +83,5 @@ export function useAdapter(): ThemeAdapter {
81
83
  }
82
84
  return adapter;
83
85
  }
86
+
87
+ export function useAdapterImage() { return useContext(AdapterContext)?.Image; }
@@ -1,3 +1,4 @@
1
+ import {StoreImage} from '../components/StoreImage';
1
2
  /**
2
3
  * Port of blocks/_slide.liquid — hero slide: full-bleed media canvas.
3
4
  * Content lives in nested _box blocks. CSS: blocks-_slide.css
@@ -70,7 +71,7 @@ export default function SlideBlock({
70
71
  sizes="100vw"
71
72
  />
72
73
  )}
73
- <img
74
+ <StoreImage
74
75
  className="hero__media"
75
76
  src={imageUrl(image, 3000)}
76
77
  srcSet={[750, 1100, 1500, 2200, 3000]
@@ -1,3 +1,4 @@
1
+ import {StoreImage} from '../components/StoreImage';
1
2
  /**
2
3
  * Port of blocks/_video-card.liquid — video carousel card: a muted
3
4
  * preview loop that expands into the shared video modal (main video
@@ -83,7 +84,7 @@ export default function VideoCardBlock({
83
84
  <source src={previewMp4.url} type={previewMp4.mime_type} />
84
85
  </video>
85
86
  ) : poster ? (
86
- <img
87
+ <StoreImage
87
88
  className="video-card__media"
88
89
  src={imageUrl(poster, 800)}
89
90
  loading="lazy"
@@ -100,7 +101,7 @@ export default function VideoCardBlock({
100
101
  {product ? (
101
102
  <a className="video-card__product" href={product.url}>
102
103
  {productImage ? (
103
- <img
104
+ <StoreImage
104
105
  className="video-card__product-image"
105
106
  src={imageUrl(productImage, 96)}
106
107
  loading="lazy"
@@ -1,3 +1,4 @@
1
+ import {StoreImage} from '../components/StoreImage';
1
2
  /** Port of blocks/image.liquid. CSS: app/styles/components/blocks-image.css */
2
3
  import type {BlockProps} from '../engine/types';
3
4
  import {imageSrcSet, imageUrl, PlaceholderSvg, settingImageUrl} from '../engine/images';
@@ -13,7 +14,7 @@ export default function ImageBlock({settings}: BlockProps<ImageSettings>) {
13
14
  return (
14
15
  <div className={`image-block image-block--${aspect}`}>
15
16
  {src ? (
16
- <img
17
+ <StoreImage
17
18
  className="image-block__image"
18
19
  src={imageUrl(src, 1500)}
19
20
  srcSet={imageSrcSet(src)}
@@ -1,3 +1,4 @@
1
+ import {StoreImage} from '../components/StoreImage';
1
2
  /**
2
3
  * Port of the section-local `story` block from sections/stories.liquid:
3
4
  * a circular/square thumbnail with an optional label and link.
@@ -24,7 +25,7 @@ export default function StoryBlock({settings}: BlockProps<StorySettings>) {
24
25
  <a className="story" role="listitem" href={settings.link || undefined}>
25
26
  <span className="story__media">
26
27
  {image ? (
27
- <img
28
+ <StoreImage
28
29
  className="story__image"
29
30
  src={imageUrl(image, 480)}
30
31
  loading="lazy"
@@ -1,3 +1,4 @@
1
+ import {StoreImage} from '../components/StoreImage';
1
2
  /**
2
3
  * Port of blocks/video.liquid — general video block: a Shopify-hosted
3
4
  * file (ambient loop or click-to-play with native controls) or a
@@ -94,7 +95,7 @@ export default function VideoBlock({settings}: BlockProps<VideoSettings>) {
94
95
  hidden={playing}
95
96
  >
96
97
  {poster ? (
97
- <img
98
+ <StoreImage
98
99
  className="video-block__media"
99
100
  src={imageUrl(poster, 1500)}
100
101
  srcSet={[400, 700, 1000, 1500]
@@ -1,3 +1,4 @@
1
+ import {StoreImage} from './StoreImage';
1
2
  /**
2
3
  * Port of snippets/product-card.liquid — a product card for grids:
3
4
  * square media (second image on hover), title, price, and badges
@@ -67,7 +68,7 @@ export function ProductCard({product, loading = 'lazy'}: ProductCardProps) {
67
68
  : null;
68
69
 
69
70
  // Swatch-hover preview, crossfading over the base image. The Liquid
70
- // island creates the <img> on first hover; React renders it once a
71
+ // island creates the <StoreImage> on first hover; React renders it once a
71
72
  // swatch has actually been hovered.
72
73
  const [preview, setPreview] = useState<{src: string; visible: boolean} | null>(
73
74
  null,
@@ -170,7 +171,7 @@ export function ProductCard({product, loading = 'lazy'}: ProductCardProps) {
170
171
  >
171
172
  {featuredImage ? (
172
173
  <>
173
- <img
174
+ <StoreImage
174
175
  className="product-card__image"
175
176
  src={imageUrl(featuredImage.url, 800)}
176
177
  srcSet={cardSrcSet(featuredImage.url)}
@@ -184,7 +185,7 @@ export function ProductCard({product, loading = 'lazy'}: ProductCardProps) {
184
185
  height={featuredImage.height ?? undefined}
185
186
  />
186
187
  {secondaryImage ? (
187
- <img
188
+ <StoreImage
188
189
  className="product-card__image product-card__image--hover"
189
190
  src={imageUrl(secondaryImage.url, 800)}
190
191
  srcSet={cardSrcSet(secondaryImage.url)}
@@ -202,7 +203,7 @@ export function ProductCard({product, loading = 'lazy'}: ProductCardProps) {
202
203
  )}
203
204
 
204
205
  {preview ? (
205
- <img
206
+ <StoreImage
206
207
  className={`product-card__image product-card__image--preview${
207
208
  preview.visible ? ' is-visible' : ''
208
209
  }`}
@@ -279,7 +280,7 @@ export function ProductCard({product, loading = 'lazy'}: ProductCardProps) {
279
280
  onMouseOver={onSwatchEnter(cardMediaUrl)}
280
281
  aria-label={`${product.title} – ${value.name}`}
281
282
  >
282
- <img
283
+ <StoreImage
283
284
  src={imageUrl(targetVariant.image.url, 80)}
284
285
  loading="lazy"
285
286
  alt={targetVariant.image.altText ?? ''}
@@ -0,0 +1,6 @@
1
+ import type {ImgHTMLAttributes} from 'react';
2
+ import {useAdapterImage} from '../adapter';
3
+ export function StoreImage(props: ImgHTMLAttributes<HTMLImageElement>) {
4
+ const Image = useAdapterImage();
5
+ return Image ? <Image {...props} /> : <img loading="lazy" decoding="async" {...props} />;
6
+ }
@@ -1,3 +1,4 @@
1
+ import {StoreImage} from './StoreImage';
1
2
  /**
2
3
  * Port of snippets/video-modal.liquid — expanded video player dialog
3
4
  * shared by the video carousel cards and the video bubble. Built on a
@@ -196,7 +197,7 @@ export function VideoModal({
196
197
  <source src={mp4.url} type={mp4.mime_type} />
197
198
  </video>
198
199
  ) : poster ? (
199
- <img
200
+ <StoreImage
200
201
  className="video-modal__video"
201
202
  src={imageUrl(poster, 1100)}
202
203
  loading="lazy"
@@ -210,7 +211,7 @@ export function VideoModal({
210
211
  {product ? (
211
212
  <a className="video-modal__product" href={product.url}>
212
213
  {productImage ? (
213
- <img
214
+ <StoreImage
214
215
  className="video-modal__product-image"
215
216
  src={imageUrl(productImage, 120)}
216
217
  loading="lazy"
@@ -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 function imageUrl(url: string, width?: number): string {
22
- if (!width || !url.includes('cdn.shopify.com')) return url;
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(
@@ -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
- <img
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
- <img src={imageUrl(backgroundImage, 2000)} alt="" />
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
- <img
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
- <img
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
+ }