@zalify/storefront-kit 0.1.12 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/commerce/disclosure-motion.d.ts +2 -0
  2. package/dist/commerce/disclosure-motion.js +69 -0
  3. package/dist/commerce/image-url.d.ts +2 -0
  4. package/dist/commerce/image-url.js +15 -0
  5. package/dist/commerce/index.d.ts +1 -0
  6. package/dist/commerce/index.js +1 -0
  7. package/dist/editor/bootstrap.d.ts +15 -0
  8. package/dist/editor/bootstrap.js +42 -0
  9. package/dist/editor/draft.d.ts +17 -0
  10. package/dist/editor/draft.js +32 -0
  11. package/dist/editor/frame.d.ts +2 -0
  12. package/dist/editor/frame.js +7 -4
  13. package/dist/react/adapter.d.ts +3 -0
  14. package/dist/react/adapter.js +1 -0
  15. package/dist/react/blocks/_slide.js +2 -1
  16. package/dist/react/blocks/_video-card.js +2 -1
  17. package/dist/react/blocks/image.js +2 -1
  18. package/dist/react/blocks/story.js +2 -1
  19. package/dist/react/blocks/video.js +2 -1
  20. package/dist/react/components/ProductCard.js +4 -3
  21. package/dist/react/components/StoreImage.d.ts +2 -0
  22. package/dist/react/components/StoreImage.js +6 -0
  23. package/dist/react/components/VideoModal.js +2 -1
  24. package/dist/react/engine/CssVariables.js +5 -4
  25. package/dist/react/engine/images.d.ts +1 -1
  26. package/dist/react/engine/images.js +2 -6
  27. package/dist/react/engine/render.js +3 -1
  28. package/dist/react/engine/store.d.ts +6 -0
  29. package/dist/react/engine/store.js +34 -3
  30. package/dist/react/index.d.ts +1 -0
  31. package/dist/react/index.js +1 -0
  32. package/dist/react/sections/article.js +2 -1
  33. package/dist/react/sections/custom-section.js +2 -1
  34. package/dist/react/sections/image-with-text.js +2 -1
  35. package/dist/react/sections/video-bubble.js +2 -1
  36. package/dist/react/useEditorTemplate.d.ts +15 -0
  37. package/dist/react/useEditorTemplate.js +114 -0
  38. package/dist/schemas/bridge.d.ts +1 -0
  39. package/package.json +1 -1
  40. package/src/commerce/disclosure-motion.ts +60 -0
  41. package/src/commerce/image-url.ts +11 -0
  42. package/src/commerce/index.ts +2 -0
  43. package/src/editor/bootstrap.ts +61 -0
  44. package/src/editor/draft.ts +54 -0
  45. package/src/editor/frame.ts +7 -3
  46. package/src/react/adapter.tsx +4 -0
  47. package/src/react/blocks/_slide.tsx +2 -1
  48. package/src/react/blocks/_video-card.tsx +3 -2
  49. package/src/react/blocks/image.tsx +2 -1
  50. package/src/react/blocks/story.tsx +2 -1
  51. package/src/react/blocks/video.tsx +2 -1
  52. package/src/react/components/ProductCard.tsx +6 -5
  53. package/src/react/components/StoreImage.tsx +6 -0
  54. package/src/react/components/VideoModal.tsx +3 -2
  55. package/src/react/engine/CssVariables.tsx +34 -10
  56. package/src/react/engine/images.tsx +2 -5
  57. package/src/react/engine/render.tsx +14 -1
  58. package/src/react/engine/store.ts +43 -4
  59. package/src/react/index.ts +2 -0
  60. package/src/react/sections/article.tsx +2 -1
  61. package/src/react/sections/custom-section.tsx +2 -1
  62. package/src/react/sections/image-with-text.tsx +2 -1
  63. package/src/react/sections/video-bubble.tsx +2 -1
  64. package/src/react/useEditorTemplate.ts +136 -0
  65. package/src/schemas/bridge.ts +1 -0
@@ -7,9 +7,15 @@
7
7
  * Static token scales (spacing, type, radii) live in the app's
8
8
  * critical.css (synced from the theme).
9
9
  */
10
- import {useMemo} from 'react';
10
+ import {useMemo, useSyncExternalStore} from 'react';
11
11
  import {colorSchemes, themeSettings} from './settings';
12
- import {getThemeStoreVersion} from './store';
12
+ import {
13
+ getThemeStoreVersion,
14
+ subscribePreview,
15
+ getPreviewVersion,
16
+ getServerPreviewVersion,
17
+ hasThemePreview,
18
+ } from './store';
13
19
  import {
14
20
  googleFontsHrefsFromSettings,
15
21
  parseFontHandle,
@@ -35,7 +41,9 @@ function buildCss(): string {
35
41
  const lines: string[] = [];
36
42
  lines.push(':root {');
37
43
  lines.push(` --font-body--family: ${bodyFamily};`);
38
- lines.push(` --font-body--style: ${bodyGoogle ? 'normal' : bodyFont.style};`);
44
+ lines.push(
45
+ ` --font-body--style: ${bodyGoogle ? 'normal' : bodyFont.style};`,
46
+ );
39
47
  lines.push(` --font-body--weight: ${bodyGoogle ? 400 : bodyFont.weight};`);
40
48
  lines.push(` --font-heading--family: ${headingFamily};`);
41
49
  if (monoGoogle) {
@@ -52,15 +60,23 @@ function buildCss(): string {
52
60
  }
53
61
  lines.push(` --page-width: ${s.max_page_width ?? '90rem'};`);
54
62
  lines.push(` --page-margin: ${s.min_page_margin ?? 20}px;`);
55
- lines.push(` --style-border-radius-inputs: ${s.input_corner_radius ?? 0}px;`);
56
- lines.push(` --style-border-radius-buttons: ${s.button_corner_radius ?? 0}px;`);
63
+ lines.push(
64
+ ` --style-border-radius-inputs: ${s.input_corner_radius ?? 0}px;`,
65
+ );
66
+ lines.push(
67
+ ` --style-border-radius-buttons: ${s.button_corner_radius ?? 0}px;`,
68
+ );
57
69
  lines.push(` --style-border-radius-cards: ${s.card_corner_radius ?? 0}px;`);
58
- lines.push(` --style-badge-background: ${s.card_badge_background ?? '#F9EDEF'};`);
70
+ lines.push(
71
+ ` --style-badge-background: ${s.card_badge_background ?? '#F9EDEF'};`,
72
+ );
59
73
  lines.push(` --style-badge-text: ${s.card_badge_text ?? '#1A1A1A'};`);
60
74
  lines.push(
61
75
  ` --style-badge-sale-background: ${s.card_badge_sale_background ?? '#F26B8A'};`,
62
76
  );
63
- lines.push(` --style-badge-sale-text: ${s.card_badge_sale_text ?? '#FFFFFF'};`);
77
+ lines.push(
78
+ ` --style-badge-sale-text: ${s.card_badge_sale_text ?? '#FFFFFF'};`,
79
+ );
64
80
  lines.push(
65
81
  ` --style-badge-1-background: ${s.badge_style_1_background ?? '#1A1A1A'};`,
66
82
  );
@@ -137,6 +153,11 @@ export function CssVariables({
137
153
  nonce,
138
154
  fonts,
139
155
  }: {nonce?: string; fonts?: ResolvedGoogleFontCss | null} = {}) {
156
+ useSyncExternalStore(
157
+ subscribePreview,
158
+ getPreviewVersion,
159
+ getServerPreviewVersion,
160
+ );
140
161
  const themeVersion = getThemeStoreVersion();
141
162
  const {css, links} = useMemo(
142
163
  () => ({css: buildCss(), links: googleFontsHrefs()}),
@@ -147,7 +168,7 @@ export function CssVariables({
147
168
  // inlined and the latin woff2 files preloaded, so the font downloads
148
169
  // with the HTML and usually beats first paint — no async stylesheet,
149
170
  // no FOUT, no request to fonts.googleapis.com at all.
150
- if (fonts) {
171
+ if (fonts && !hasThemePreview()) {
151
172
  return (
152
173
  <>
153
174
  <link
@@ -182,13 +203,16 @@ export function CssVariables({
182
203
  {links.map((href) => (
183
204
  <link
184
205
  key={href}
185
- rel="preload"
206
+ rel={hasThemePreview() ? 'stylesheet' : 'preload'}
186
207
  as="style"
187
208
  href={href}
188
209
  data-zfy-font-css=""
189
210
  />
190
211
  ))}
191
- <script nonce={nonce} dangerouslySetInnerHTML={{__html: FONT_CSS_LOADER}} />
212
+ <script
213
+ nonce={nonce}
214
+ dangerouslySetInnerHTML={{__html: FONT_CSS_LOADER}}
215
+ />
192
216
  <noscript>
193
217
  {links.map((href) => (
194
218
  <link key={href} rel="stylesheet" href={href} />
@@ -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(
@@ -15,7 +15,15 @@
15
15
  * emitted unconditionally so server and client markup stay identical.
16
16
  */
17
17
  import {formatThemePath, DATA_PATH_ATTR} from '../../schemas/index.ts';
18
- import {getSectionGroup, getTemplate, getThemeStore} from './store';
18
+ import {
19
+ getSectionGroup,
20
+ getTemplate,
21
+ getThemeStore,
22
+ subscribePreview,
23
+ getPreviewVersion,
24
+ getServerPreviewVersion,
25
+ } from './store';
26
+ import {useSyncExternalStore} from 'react';
19
27
  import {SectionProvider, TemplateProvider} from './context';
20
28
  import type {BlockData, SectionData, TemplateData} from './types';
21
29
 
@@ -181,6 +189,11 @@ export function SectionGroup({
181
189
  name: string;
182
190
  sectionData?: Record<string, unknown>;
183
191
  }) {
192
+ useSyncExternalStore(
193
+ subscribePreview,
194
+ getPreviewVersion,
195
+ getServerPreviewVersion,
196
+ );
184
197
  const group = getSectionGroup(name);
185
198
  return (
186
199
  <TemplateProvider value={{name, kind: 'group', resources: {}, sectionData}}>
@@ -26,7 +26,10 @@ export interface ThemeSchema {
26
26
  /** sections/*-group.json keyed by file name ("header-group"…). */
27
27
  sectionGroups: Record<string, SectionGroupData>;
28
28
  /** config/settings_schema.json. */
29
- settingsSchema: Array<{name: string; settings?: Array<Record<string, unknown>>}>;
29
+ settingsSchema: Array<{
30
+ name: string;
31
+ settings?: Array<Record<string, unknown>>;
32
+ }>;
30
33
  /** config/settings_data.json. */
31
34
  settingsData: {
32
35
  current: string | Record<string, unknown>;
@@ -57,6 +60,40 @@ interface ThemeStore extends InstallThemeOptions {}
57
60
 
58
61
  let store: ThemeStore | null = null;
59
62
  let version = 0;
63
+ let preview: {owner: ThemeStore; value: ThemeStore} | null = null;
64
+ let previewVersion = 0;
65
+ const previewListeners = new Set<() => void>();
66
+
67
+ export function subscribePreview(listener: () => void): () => void {
68
+ previewListeners.add(listener);
69
+ return () => {
70
+ previewListeners.delete(listener);
71
+ };
72
+ }
73
+ export function getPreviewVersion(): number {
74
+ return previewVersion;
75
+ }
76
+ export function getServerPreviewVersion(): number {
77
+ return 0;
78
+ }
79
+ export function hasThemePreview(): boolean {
80
+ return (
81
+ typeof window !== 'undefined' && preview !== null && preview.owner === store
82
+ );
83
+ }
84
+
85
+ /** Browser-only overlay; the installed schema and SSR state remain untouched. */
86
+ export function setThemePreview(
87
+ owner: ThemeStore,
88
+ schema: ThemeSchema | null,
89
+ ): void {
90
+ if (typeof window === 'undefined' || owner !== store) return;
91
+ preview = schema
92
+ ? {owner, value: {...owner, schema, settingsOverride: undefined}}
93
+ : null;
94
+ previewVersion++;
95
+ for (const listener of previewListeners) listener();
96
+ }
60
97
 
61
98
  export function installTheme(options: InstallThemeOptions): void {
62
99
  store = options;
@@ -67,17 +104,19 @@ export function installTheme(options: InstallThemeOptions): void {
67
104
 
68
105
  /** Monotonic install counter — lets derived caches detect re-installs. */
69
106
  export function getThemeStoreVersion(): number {
70
- return version;
107
+ return version + (typeof window === 'undefined' ? 0 : previewVersion);
71
108
  }
72
109
 
73
110
  export function getThemeStore(): ThemeStore {
74
111
  if (!store) {
75
112
  throw new Error(
76
- '[storefront-kit] installTheme() has not been called. Import your app\'s ' +
113
+ "[storefront-kit] installTheme() has not been called. Import your app's " +
77
114
  'theme-setup module (which calls installTheme) before rendering.',
78
115
  );
79
116
  }
80
- return store;
117
+ return typeof window !== 'undefined' && preview?.owner === store
118
+ ? preview.value
119
+ : store;
81
120
  }
82
121
 
83
122
  /** Look up a page template ("index", "product", "customers/login"…). */
@@ -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,136 @@
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
+ import type { PreviewContext } from "../schemas/bridge";
7
+ import type { EditorDocuments } from "../editor/bootstrap";
8
+ import { getThemeStore, setThemePreview } from "./engine/store";
9
+
10
+ type Options = {
11
+ origins: readonly string[];
12
+ loadManifest: () => Promise<ThemeEditorManifest>;
13
+ /** Authoritative, repository-relative merchant data targets. */
14
+ paths: EditorDocuments;
15
+ /** Real route targets keyed by template name. */
16
+ previews: Record<string, PreviewContext>;
17
+ };
18
+
19
+ /** No editor code or schema is fetched outside an explicitly allowed preview. */
20
+ export function useEditorTemplate(
21
+ name: string,
22
+ options: Options,
23
+ ): TemplateData | null {
24
+ const [draft, setDraft] = useState<{
25
+ name: string;
26
+ template: TemplateData;
27
+ } | null>(null);
28
+ const controller = useRef<FrameBridgeController | null>(null);
29
+ useEffect(() => {
30
+ setDraft(null);
31
+ if (
32
+ !options.origins.length ||
33
+ window.self === window.top ||
34
+ new URL(window.location.href).searchParams.get("zalify-editor") !== "1"
35
+ )
36
+ return;
37
+ let parentOrigin: string;
38
+ try {
39
+ parentOrigin = new URL(document.referrer).origin;
40
+ } catch {
41
+ return;
42
+ }
43
+ if (!options.origins.includes(parentOrigin)) return;
44
+ let disposed = false;
45
+ const owner = getThemeStore();
46
+ void Promise.all([
47
+ import("../editor/frame"),
48
+ options.loadManifest(),
49
+ import("../editor/bootstrap"),
50
+ import("../editor/draft"),
51
+ ])
52
+ .then(([bridge, manifest, bootstrapModule, draftModule]) => {
53
+ if (disposed) return;
54
+ const settings = owner.schema.settingsData;
55
+ const current =
56
+ typeof settings.current === "string"
57
+ ? (settings.presets?.[settings.current] ?? {})
58
+ : settings.current;
59
+ const schema = {
60
+ ...owner.schema,
61
+ settingsData: {
62
+ ...settings,
63
+ current: { ...current, ...owner.settingsOverride },
64
+ },
65
+ };
66
+ const previews = { ...options.previews };
67
+ // The currently rendered route is also a verified context, including locale.
68
+ const url = new URL(window.location.href);
69
+ url.searchParams.delete("zalify-editor");
70
+ const kind = name.split(".")[0];
71
+ const resourceType = (
72
+ [
73
+ "index",
74
+ "product",
75
+ "collection",
76
+ "page",
77
+ "blog",
78
+ "article",
79
+ "cart",
80
+ "search",
81
+ "list-collections",
82
+ "404",
83
+ ].includes(kind)
84
+ ? kind
85
+ : "page"
86
+ ) as PreviewContext["resourceType"];
87
+ previews[name] = {
88
+ ...previews[name],
89
+ id: name,
90
+ title: previews[name]?.title ?? name,
91
+ resourceType,
92
+ url: url.pathname + url.search,
93
+ };
94
+ const bootstrap = bootstrapModule.createEditorBootstrap({
95
+ schema,
96
+ manifest,
97
+ paths: options.paths,
98
+ previews,
99
+ });
100
+ const draft = draftModule.createEditorDraft(schema);
101
+ controller.current = bridge.mountFrameBridge({
102
+ editorOrigin: parentOrigin,
103
+ templateName: name,
104
+ hash: manifest.hash,
105
+ getManifest: () => manifest,
106
+ capabilities: [
107
+ "editor-bootstrap-v1",
108
+ "apply-template-v1",
109
+ "apply-groups-v1",
110
+ "apply-settings-v1",
111
+ "preview-navigation-v1",
112
+ ],
113
+ getBootstrap: () => bootstrap,
114
+ applyTemplate(payload) {
115
+ if (payload.templateName !== name) return false;
116
+ setThemePreview(owner, { ...schema, ...draft.apply(payload) });
117
+ setDraft({ name, template: payload.template });
118
+ return true;
119
+ },
120
+ });
121
+ })
122
+ .catch(() => {
123
+ /* The storefront remains usable if preview assets fail. */
124
+ });
125
+ return () => {
126
+ disposed = true;
127
+ controller.current?.unmount();
128
+ controller.current = null;
129
+ setThemePreview(owner, null);
130
+ };
131
+ }, [name, options]);
132
+ useEffect(() => {
133
+ controller.current?.reportRects();
134
+ }, [draft]);
135
+ return draft?.name === name ? draft.template : null;
136
+ }
@@ -185,6 +185,7 @@ export type FrameMessage =
185
185
  deltaX: number;
186
186
  deltaY: number;
187
187
  ctrlKey: boolean;
188
+ shiftKey?: boolean;
188
189
  clientX: number;
189
190
  clientY: number;
190
191
  }