@zalify/storefront-kit 0.1.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 (213) hide show
  1. package/LICENSE.md +49 -0
  2. package/README.md +63 -0
  3. package/dist/commerce/countdown.d.ts +18 -0
  4. package/dist/commerce/countdown.js +28 -0
  5. package/dist/commerce/events.d.ts +29 -0
  6. package/dist/commerce/events.js +14 -0
  7. package/dist/commerce/facets.d.ts +27 -0
  8. package/dist/commerce/facets.js +71 -0
  9. package/dist/commerce/index.d.ts +11 -0
  10. package/dist/commerce/index.js +11 -0
  11. package/dist/commerce/money.d.ts +51 -0
  12. package/dist/commerce/money.js +83 -0
  13. package/dist/commerce/product.d.ts +102 -0
  14. package/dist/commerce/product.js +93 -0
  15. package/dist/editor/frame.d.ts +57 -0
  16. package/dist/editor/frame.js +241 -0
  17. package/dist/editor/host.d.ts +31 -0
  18. package/dist/editor/host.js +67 -0
  19. package/dist/editor/index.d.ts +9 -0
  20. package/dist/editor/index.js +9 -0
  21. package/dist/react/adapter.d.ts +49 -0
  22. package/dist/react/adapter.js +23 -0
  23. package/dist/react/blocks/_box.d.ts +10 -0
  24. package/dist/react/blocks/_box.js +17 -0
  25. package/dist/react/blocks/_slide.d.ts +24 -0
  26. package/dist/react/blocks/_slide.js +21 -0
  27. package/dist/react/blocks/_video-card.d.ts +11 -0
  28. package/dist/react/blocks/_video-card.js +39 -0
  29. package/dist/react/blocks/accordion.d.ts +12 -0
  30. package/dist/react/blocks/accordion.js +5 -0
  31. package/dist/react/blocks/button.d.ts +10 -0
  32. package/dist/react/blocks/button.js +15 -0
  33. package/dist/react/blocks/countdown.d.ts +9 -0
  34. package/dist/react/blocks/countdown.js +64 -0
  35. package/dist/react/blocks/custom-liquid.d.ts +11 -0
  36. package/dist/react/blocks/custom-liquid.js +4 -0
  37. package/dist/react/blocks/group.d.ts +9 -0
  38. package/dist/react/blocks/group.js +8 -0
  39. package/dist/react/blocks/image.d.ts +8 -0
  40. package/dist/react/blocks/image.js +7 -0
  41. package/dist/react/blocks/message.d.ts +14 -0
  42. package/dist/react/blocks/message.js +5 -0
  43. package/dist/react/blocks/quote.d.ts +9 -0
  44. package/dist/react/blocks/quote.js +5 -0
  45. package/dist/react/blocks/spacer.d.ts +8 -0
  46. package/dist/react/blocks/spacer.js +5 -0
  47. package/dist/react/blocks/story.d.ts +17 -0
  48. package/dist/react/blocks/story.js +7 -0
  49. package/dist/react/blocks/text.d.ts +9 -0
  50. package/dist/react/blocks/text.js +5 -0
  51. package/dist/react/blocks/video.d.ts +17 -0
  52. package/dist/react/blocks/video.js +56 -0
  53. package/dist/react/components/Facets.d.ts +21 -0
  54. package/dist/react/components/Facets.js +220 -0
  55. package/dist/react/components/Icon.d.ts +12 -0
  56. package/dist/react/components/Icon.js +66 -0
  57. package/dist/react/components/PaymentIcons.d.ts +5 -0
  58. package/dist/react/components/PaymentIcons.generated.d.ts +22 -0
  59. package/dist/react/components/PaymentIcons.generated.js +21 -0
  60. package/dist/react/components/PaymentIcons.js +18 -0
  61. package/dist/react/components/Price.d.ts +24 -0
  62. package/dist/react/components/Price.js +9 -0
  63. package/dist/react/components/ProductBadges.d.ts +24 -0
  64. package/dist/react/components/ProductBadges.js +57 -0
  65. package/dist/react/components/ProductCard.d.ts +17 -0
  66. package/dist/react/components/ProductCard.js +202 -0
  67. package/dist/react/components/Rating.d.ts +11 -0
  68. package/dist/react/components/Rating.js +35 -0
  69. package/dist/react/components/SwatchStyle.d.ts +23 -0
  70. package/dist/react/components/SwatchStyle.js +22 -0
  71. package/dist/react/components/VideoModal.d.ts +55 -0
  72. package/dist/react/components/VideoModal.js +102 -0
  73. package/dist/react/engine/CssVariables.d.ts +1 -0
  74. package/dist/react/engine/CssVariables.js +105 -0
  75. package/dist/react/engine/context.d.ts +43 -0
  76. package/dist/react/engine/context.js +45 -0
  77. package/dist/react/engine/fonts.d.ts +15 -0
  78. package/dist/react/engine/fonts.js +33 -0
  79. package/dist/react/engine/images.d.ts +24 -0
  80. package/dist/react/engine/images.js +40 -0
  81. package/dist/react/engine/render.d.ts +29 -0
  82. package/dist/react/engine/render.js +87 -0
  83. package/dist/react/engine/settings.d.ts +8 -0
  84. package/dist/react/engine/settings.js +68 -0
  85. package/dist/react/engine/store.d.ts +64 -0
  86. package/dist/react/engine/store.js +42 -0
  87. package/dist/react/engine/translate.d.ts +7 -0
  88. package/dist/react/engine/translate.js +33 -0
  89. package/dist/react/engine/types.d.ts +128 -0
  90. package/dist/react/engine/types.js +1 -0
  91. package/dist/react/index.d.ts +38 -0
  92. package/dist/react/index.js +39 -0
  93. package/dist/react/registries.d.ts +10 -0
  94. package/dist/react/registries.js +68 -0
  95. package/dist/react/registries.server.d.ts +2 -0
  96. package/dist/react/registries.server.js +98 -0
  97. package/dist/react/sections/404.d.ts +10 -0
  98. package/dist/react/sections/404.js +10 -0
  99. package/dist/react/sections/announcement-bar.d.ts +14 -0
  100. package/dist/react/sections/announcement-bar.js +84 -0
  101. package/dist/react/sections/article.d.ts +17 -0
  102. package/dist/react/sections/article.js +31 -0
  103. package/dist/react/sections/contact-form.d.ts +16 -0
  104. package/dist/react/sections/contact-form.js +8 -0
  105. package/dist/react/sections/custom-liquid.d.ts +14 -0
  106. package/dist/react/sections/custom-liquid.js +7 -0
  107. package/dist/react/sections/custom-section.d.ts +14 -0
  108. package/dist/react/sections/custom-section.js +9 -0
  109. package/dist/react/sections/featured-collection.d.ts +10 -0
  110. package/dist/react/sections/featured-collection.js +23 -0
  111. package/dist/react/sections/hero.d.ts +6 -0
  112. package/dist/react/sections/hero.js +35 -0
  113. package/dist/react/sections/image-with-text.d.ts +13 -0
  114. package/dist/react/sections/image-with-text.js +9 -0
  115. package/dist/react/sections/multicolumn.d.ts +15 -0
  116. package/dist/react/sections/multicolumn.js +8 -0
  117. package/dist/react/sections/page.d.ts +13 -0
  118. package/dist/react/sections/page.js +11 -0
  119. package/dist/react/sections/password.d.ts +15 -0
  120. package/dist/react/sections/password.js +9 -0
  121. package/dist/react/sections/rich-text.d.ts +12 -0
  122. package/dist/react/sections/rich-text.js +7 -0
  123. package/dist/react/sections/stories.d.ts +18 -0
  124. package/dist/react/sections/stories.js +10 -0
  125. package/dist/react/sections/video-bubble.d.ts +13 -0
  126. package/dist/react/sections/video-bubble.js +54 -0
  127. package/dist/react/sections/video-carousel.d.ts +19 -0
  128. package/dist/react/sections/video-carousel.js +9 -0
  129. package/dist/react/server.d.ts +7 -0
  130. package/dist/react/server.js +42 -0
  131. package/dist/schemas/bridge.d.ts +98 -0
  132. package/dist/schemas/bridge.js +50 -0
  133. package/dist/schemas/data.d.ts +54 -0
  134. package/dist/schemas/data.js +22 -0
  135. package/dist/schemas/index.d.ts +14 -0
  136. package/dist/schemas/index.js +14 -0
  137. package/dist/schemas/manifest.d.ts +63 -0
  138. package/dist/schemas/manifest.js +32 -0
  139. package/dist/schemas/schema.d.ts +159 -0
  140. package/dist/schemas/schema.js +129 -0
  141. package/dist/schemas/validate.d.ts +22 -0
  142. package/dist/schemas/validate.js +199 -0
  143. package/package.json +84 -0
  144. package/src/commerce/countdown.ts +38 -0
  145. package/src/commerce/events.ts +43 -0
  146. package/src/commerce/facets.ts +88 -0
  147. package/src/commerce/index.ts +11 -0
  148. package/src/commerce/money.ts +120 -0
  149. package/src/commerce/product.ts +177 -0
  150. package/src/editor/frame.ts +305 -0
  151. package/src/editor/host.ts +101 -0
  152. package/src/editor/index.ts +9 -0
  153. package/src/react/adapter.tsx +83 -0
  154. package/src/react/blocks/_box.tsx +37 -0
  155. package/src/react/blocks/_slide.tsx +96 -0
  156. package/src/react/blocks/_video-card.tsx +131 -0
  157. package/src/react/blocks/accordion.tsx +33 -0
  158. package/src/react/blocks/button.tsx +36 -0
  159. package/src/react/blocks/countdown.tsx +105 -0
  160. package/src/react/blocks/custom-liquid.tsx +21 -0
  161. package/src/react/blocks/group.tsx +32 -0
  162. package/src/react/blocks/image.tsx +29 -0
  163. package/src/react/blocks/message.tsx +28 -0
  164. package/src/react/blocks/quote.tsx +24 -0
  165. package/src/react/blocks/spacer.tsx +17 -0
  166. package/src/react/blocks/story.tsx +40 -0
  167. package/src/react/blocks/text.tsx +24 -0
  168. package/src/react/blocks/video.tsx +132 -0
  169. package/src/react/components/Facets.tsx +424 -0
  170. package/src/react/components/Icon.tsx +96 -0
  171. package/src/react/components/PaymentIcons.generated.ts +24 -0
  172. package/src/react/components/PaymentIcons.tsx +27 -0
  173. package/src/react/components/Price.tsx +72 -0
  174. package/src/react/components/ProductBadges.tsx +95 -0
  175. package/src/react/components/ProductCard.tsx +417 -0
  176. package/src/react/components/Rating.tsx +59 -0
  177. package/src/react/components/SwatchStyle.tsx +50 -0
  178. package/src/react/components/VideoModal.tsx +236 -0
  179. package/src/react/engine/CssVariables.tsx +142 -0
  180. package/src/react/engine/context.tsx +108 -0
  181. package/src/react/engine/fonts.ts +42 -0
  182. package/src/react/engine/images.tsx +58 -0
  183. package/src/react/engine/render.tsx +190 -0
  184. package/src/react/engine/settings.ts +89 -0
  185. package/src/react/engine/store.ts +103 -0
  186. package/src/react/engine/translate.ts +40 -0
  187. package/src/react/engine/types.ts +142 -0
  188. package/src/react/index.ts +78 -0
  189. package/src/react/registries.server.ts +110 -0
  190. package/src/react/registries.ts +80 -0
  191. package/src/react/sections/404.tsx +37 -0
  192. package/src/react/sections/announcement-bar.tsx +170 -0
  193. package/src/react/sections/article.tsx +97 -0
  194. package/src/react/sections/contact-form.tsx +104 -0
  195. package/src/react/sections/custom-liquid.tsx +39 -0
  196. package/src/react/sections/custom-section.tsx +44 -0
  197. package/src/react/sections/featured-collection.tsx +89 -0
  198. package/src/react/sections/hero.tsx +84 -0
  199. package/src/react/sections/image-with-text.tsx +61 -0
  200. package/src/react/sections/multicolumn.tsx +46 -0
  201. package/src/react/sections/page.tsx +43 -0
  202. package/src/react/sections/password.tsx +60 -0
  203. package/src/react/sections/rich-text.tsx +37 -0
  204. package/src/react/sections/stories.tsx +51 -0
  205. package/src/react/sections/video-bubble.tsx +145 -0
  206. package/src/react/sections/video-carousel.tsx +57 -0
  207. package/src/react/server.ts +60 -0
  208. package/src/schemas/bridge.ts +143 -0
  209. package/src/schemas/data.ts +72 -0
  210. package/src/schemas/index.ts +14 -0
  211. package/src/schemas/manifest.ts +85 -0
  212. package/src/schemas/schema.ts +287 -0
  213. package/src/schemas/validate.ts +302 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Port of snippets/video-modal.liquid — expanded video player dialog
3
+ * shared by the video carousel cards and the video bubble. Built on a
4
+ * native <dialog>, mirroring the <theme-dialog> semantics from
5
+ * src/lib/dialog.ts (showModal/close, light dismiss on backdrop click,
6
+ * data-dialog-close ≙ the close button) and the play-on-open /
7
+ * pause-on-close wiring from src/entries/video.ts.
8
+ * CSS: app/styles/components/snippets-video-modal.css
9
+ */
10
+ import {useEffect, useRef} from 'react';
11
+ import type {Money} from '../../commerce/index.ts';
12
+ import {t} from '../engine/translate';
13
+ import {Icon} from './Icon';
14
+ import {imageUrl, PlaceholderSvg, settingImageUrl} from '../engine/images';
15
+ import {Price} from './Price';
16
+
17
+ /* ------------------------------------------------------------------ *
18
+ * Shared video-setting helpers (used by blocks/video, blocks/_video-card
19
+ * and sections/video-bubble — the React mirror of the `video` object
20
+ * filters in the Liquid sources).
21
+ * ------------------------------------------------------------------ */
22
+
23
+ export interface VideoSource {
24
+ url: string;
25
+ format?: string;
26
+ height?: number;
27
+ mime_type?: string;
28
+ }
29
+
30
+ /** Theme-editor `video` setting object (usually absent in the mirror). */
31
+ export interface ThemeVideo {
32
+ sources?: VideoSource[];
33
+ preview_image?: {src?: string} | string;
34
+ alt?: string;
35
+ }
36
+
37
+ /** Mirror of `video.sources | where: 'format', 'mp4' | sort: 'height'`. */
38
+ export function mp4Sources(video: ThemeVideo | undefined | null): VideoSource[] {
39
+ return (video?.sources ?? [])
40
+ .filter((s) => s.format === 'mp4')
41
+ .sort((a, b) => (a.height ?? 0) - (b.height ?? 0));
42
+ }
43
+
44
+ /** Resolve a video's preview_image to a usable URL (or null). */
45
+ export function videoPreviewImage(
46
+ video: ThemeVideo | undefined | null,
47
+ ): string | null {
48
+ const pi = video?.preview_image;
49
+ return settingImageUrl(typeof pi === 'string' ? pi : pi?.src);
50
+ }
51
+
52
+ /**
53
+ * Loose `product` setting shape. Template JSON stores product settings
54
+ * as a handle string, which only a section loader could resolve — those
55
+ * return null so callers skip the product row gracefully.
56
+ */
57
+ export interface VideoProductSetting {
58
+ url?: string;
59
+ title?: string;
60
+ featured_image?: unknown;
61
+ price?: Money;
62
+ compare_at_price?: Money | null;
63
+ }
64
+
65
+ export function resolveVideoProduct(
66
+ value: unknown,
67
+ ): VideoProductSetting | null {
68
+ if (!value || typeof value !== 'object') return null;
69
+ return value as VideoProductSetting;
70
+ }
71
+
72
+ /** Resolve a loose product image value (string or {src|url}) to a URL. */
73
+ export function productImageUrl(value: unknown): string | null {
74
+ if (typeof value === 'string') return settingImageUrl(value);
75
+ if (value && typeof value === 'object') {
76
+ const obj = value as {src?: string; url?: string};
77
+ return settingImageUrl(obj.src ?? obj.url);
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * Mirror of watchPreviews() in src/entries/video.ts: a muted
84
+ * `video[data-video-preview]` loop plays only while near the viewport.
85
+ * Attach the returned ref to the preview <video>.
86
+ */
87
+ export function useViewportPlayback() {
88
+ const ref = useRef<HTMLVideoElement>(null);
89
+ useEffect(() => {
90
+ const video = ref.current;
91
+ if (!video) return;
92
+ const io = new IntersectionObserver(
93
+ (entries) => {
94
+ for (const entry of entries) {
95
+ if (entry.isIntersecting) video.play().catch(() => {});
96
+ else video.pause();
97
+ }
98
+ },
99
+ {rootMargin: '80px'},
100
+ );
101
+ io.observe(video);
102
+ return () => io.disconnect();
103
+ }, []);
104
+ return ref;
105
+ }
106
+
107
+ /* ------------------------------------------------------------------ *
108
+ * The modal itself.
109
+ * ------------------------------------------------------------------ */
110
+
111
+ export interface VideoModalProps {
112
+ /** Mirror of the Liquid snippet's `id` param (data-dialog-open target). */
113
+ id?: string;
114
+ open: boolean;
115
+ onClose: () => void;
116
+ /** Shopify-hosted video to play (largest mp4 rendition is used). */
117
+ video?: ThemeVideo | null;
118
+ /** Cover image URL painted before playback. */
119
+ poster?: string | null;
120
+ /** Product featured in the video. */
121
+ product?: VideoProductSetting | null;
122
+ /** Accessible name for the dialog. */
123
+ label?: string;
124
+ }
125
+
126
+ export function VideoModal({
127
+ id,
128
+ open,
129
+ onClose,
130
+ video,
131
+ poster,
132
+ product,
133
+ label,
134
+ }: VideoModalProps) {
135
+ const dialogRef = useRef<HTMLDialogElement>(null);
136
+ const videoRef = useRef<HTMLVideoElement>(null);
137
+
138
+ // showModal()/close() driven by the open prop — the React mirror of
139
+ // ThemeDialog.show()/hide(). Playback starts a frame later so the
140
+ // video starts inside the open dialog (≙ playDialogVideo in video.ts).
141
+ useEffect(() => {
142
+ const dialog = dialogRef.current;
143
+ if (!dialog) return;
144
+ if (open && !dialog.open) {
145
+ dialog.showModal();
146
+ requestAnimationFrame(() => {
147
+ videoRef.current?.play().catch(() => {});
148
+ });
149
+ } else if (!open && dialog.open) {
150
+ dialog.close();
151
+ }
152
+ }, [open]);
153
+
154
+ const mp4 = mp4Sources(video).at(-1);
155
+ const modalLabel = label || t('general.play_video');
156
+ const productImage = productImageUrl(product?.featured_image);
157
+
158
+ return (
159
+ <dialog
160
+ ref={dialogRef}
161
+ id={id}
162
+ scroll-lock=""
163
+ className="video-modal"
164
+ aria-label={modalLabel}
165
+ // Expanded players stop when their dialog closes (Esc included) —
166
+ // mirror of pauseOnDialogClose in video.ts.
167
+ onClose={() => {
168
+ videoRef.current?.pause();
169
+ onClose();
170
+ }}
171
+ // Light dismiss: clicks land on the <dialog> itself only when
172
+ // they hit the backdrop (mirror of ThemeDialog's click handler).
173
+ onClick={(event) => {
174
+ if (event.target === dialogRef.current) dialogRef.current?.close();
175
+ }}
176
+ >
177
+ <button
178
+ type="button"
179
+ className="video-modal__close"
180
+ onClick={() => dialogRef.current?.close()}
181
+ aria-label={t('general.close')}
182
+ >
183
+ <Icon name="icon-close" />
184
+ </button>
185
+
186
+ <div className="video-modal__media">
187
+ {mp4 ? (
188
+ <video
189
+ ref={videoRef}
190
+ className="video-modal__video"
191
+ controls
192
+ playsInline
193
+ preload="metadata"
194
+ poster={poster ? imageUrl(poster, 1100) : undefined}
195
+ >
196
+ <source src={mp4.url} type={mp4.mime_type} />
197
+ </video>
198
+ ) : poster ? (
199
+ <img
200
+ className="video-modal__video"
201
+ src={imageUrl(poster, 1100)}
202
+ loading="lazy"
203
+ alt=""
204
+ />
205
+ ) : (
206
+ <PlaceholderSvg className="video-modal__video" />
207
+ )}
208
+ </div>
209
+
210
+ {product ? (
211
+ <a className="video-modal__product" href={product.url}>
212
+ {productImage ? (
213
+ <img
214
+ className="video-modal__product-image"
215
+ src={imageUrl(productImage, 120)}
216
+ loading="lazy"
217
+ alt=""
218
+ />
219
+ ) : null}
220
+ <span className="video-modal__product-info">
221
+ <span className="video-modal__product-title">{product.title}</span>
222
+ <span className="video-modal__product-price">
223
+ <Price
224
+ price={product.price}
225
+ compareAt={product.compare_at_price}
226
+ />
227
+ </span>
228
+ </span>
229
+ <span className="video-modal__product-cta">
230
+ {t('products.view_product')}
231
+ </span>
232
+ </a>
233
+ ) : null}
234
+ </dialog>
235
+ );
236
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * React mirror of snippets/css-variables.liquid: emits merchant-driven
3
+ * design tokens as CSS custom properties — font slots, page layout,
4
+ * corner radii, badge colors, grid density, and one class per color
5
+ * scheme (the first scheme also seeds :root).
6
+ *
7
+ * Static token scales (spacing, type, radii) live in the app's
8
+ * critical.css (synced from the theme).
9
+ */
10
+ import {useMemo} from 'react';
11
+ import {colorSchemes, themeSettings} from './settings';
12
+ import {googleFontsHref, parseFontHandle} from './fonts';
13
+
14
+ function buildCss(): string {
15
+ const s = themeSettings;
16
+ const bodyGoogle = (s.type_body_google ?? '').trim();
17
+ const headingGoogle = (s.type_heading_google ?? '').trim();
18
+ const monoGoogle = (s.type_mono_google ?? '').trim();
19
+
20
+ const bodyFont = parseFontHandle(s.type_body_font);
21
+ const headingFont = parseFontHandle(s.type_heading_font);
22
+
23
+ const bodyFamily = bodyGoogle
24
+ ? `'${bodyGoogle}', sans-serif`
25
+ : `'${bodyFont.family}', sans-serif`;
26
+ const headingFamily = headingGoogle
27
+ ? `'${headingGoogle}', var(--font-body--family)`
28
+ : `'${headingFont.family}', sans-serif`;
29
+
30
+ const lines: string[] = [];
31
+ lines.push(':root {');
32
+ lines.push(` --font-body--family: ${bodyFamily};`);
33
+ lines.push(` --font-body--style: ${bodyGoogle ? 'normal' : bodyFont.style};`);
34
+ lines.push(` --font-body--weight: ${bodyGoogle ? 400 : bodyFont.weight};`);
35
+ lines.push(` --font-heading--family: ${headingFamily};`);
36
+ if (monoGoogle) {
37
+ lines.push(
38
+ ` --font-mono--family: '${monoGoogle}', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;`,
39
+ );
40
+ // Accent elements (eyebrows, badges, footer headings) use mono only
41
+ // when chosen; otherwise they inherit via the var() fallback.
42
+ lines.push(` --font-accent--family: var(--font-mono--family);`);
43
+ } else {
44
+ lines.push(
45
+ ` --font-mono--family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;`,
46
+ );
47
+ }
48
+ lines.push(` --page-width: ${s.max_page_width ?? '90rem'};`);
49
+ lines.push(` --page-margin: ${s.min_page_margin ?? 20}px;`);
50
+ lines.push(` --style-border-radius-inputs: ${s.input_corner_radius ?? 0}px;`);
51
+ lines.push(` --style-border-radius-buttons: ${s.button_corner_radius ?? 0}px;`);
52
+ lines.push(` --style-border-radius-cards: ${s.card_corner_radius ?? 0}px;`);
53
+ lines.push(` --style-badge-background: ${s.card_badge_background ?? '#F9EDEF'};`);
54
+ lines.push(` --style-badge-text: ${s.card_badge_text ?? '#1A1A1A'};`);
55
+ lines.push(
56
+ ` --style-badge-sale-background: ${s.card_badge_sale_background ?? '#F26B8A'};`,
57
+ );
58
+ lines.push(` --style-badge-sale-text: ${s.card_badge_sale_text ?? '#FFFFFF'};`);
59
+ lines.push(
60
+ ` --style-badge-1-background: ${s.badge_style_1_background ?? '#1A1A1A'};`,
61
+ );
62
+ lines.push(` --style-badge-1-text: ${s.badge_style_1_text ?? '#FFFFFF'};`);
63
+ lines.push(
64
+ ` --style-badge-2-background: ${s.badge_style_2_background ?? '#F9EDEF'};`,
65
+ );
66
+ lines.push(` --style-badge-2-text: ${s.badge_style_2_text ?? '#1A1A1A'};`);
67
+ // Product grid density: comfortable (fluid) or compact (tight, edge-bleed on mobile)
68
+ if (s.card_density === 'compact') {
69
+ lines.push(` --product-grid-gap: 0.75rem 0.5rem;`);
70
+ lines.push(` --product-grid-margin: -0.75rem;`);
71
+ } else {
72
+ lines.push(` --product-grid-gap: clamp(0.5rem, 2.1333vw, 1rem);`);
73
+ lines.push(` --product-grid-margin: 0;`);
74
+ }
75
+ // Variant image thumbs: fully round by default; follow the card
76
+ // radius once the merchant sets one.
77
+ const cardRadius = s.card_corner_radius ?? 0;
78
+ lines.push(
79
+ ` --style-border-radius-swatch: ${cardRadius === 0 ? '999px' : `${cardRadius}px`};`,
80
+ );
81
+ lines.push('}');
82
+
83
+ for (const [index, scheme] of colorSchemes().entries()) {
84
+ const prefix = index === 0 ? ':root,\n' : '';
85
+ lines.push(`${prefix}.color-${scheme.id} {`);
86
+ lines.push(` --color-background: ${scheme.settings.background};`);
87
+ lines.push(` --color-foreground: ${scheme.settings.text};`);
88
+ lines.push(` --color-accent: ${scheme.settings.accent};`);
89
+ lines.push(` --color-button: ${scheme.settings.button};`);
90
+ lines.push(` --color-button-label: ${scheme.settings.button_label};`);
91
+ lines.push(
92
+ ` --color-border: color-mix(in oklch, ${scheme.settings.text} 15%, ${scheme.settings.background});`,
93
+ );
94
+ lines.push('}');
95
+ }
96
+
97
+ // Applying a scheme class paints the element itself
98
+ lines.push(`[class*='color-scheme-'] {`);
99
+ lines.push(` background-color: var(--color-background);`);
100
+ lines.push(` color: var(--color-foreground);`);
101
+ lines.push('}');
102
+
103
+ return lines.join('\n');
104
+ }
105
+
106
+ function fontLinks(): string[] {
107
+ const s = themeSettings;
108
+ const hrefs: string[] = [];
109
+ const bodyGoogle = (s.type_body_google ?? '').trim();
110
+ const headingGoogle = (s.type_heading_google ?? '').trim();
111
+ const monoGoogle = (s.type_mono_google ?? '').trim();
112
+ hrefs.push(
113
+ googleFontsHref(bodyGoogle || parseFontHandle(s.type_body_font).family),
114
+ );
115
+ const heading = headingGoogle || parseFontHandle(s.type_heading_font).family;
116
+ if (!hrefs[0].includes(heading.replace(/ /g, '+'))) {
117
+ hrefs.push(googleFontsHref(heading, 'wght@400;500;600;700'));
118
+ }
119
+ if (monoGoogle) hrefs.push(googleFontsHref(monoGoogle, 'wght@400;500'));
120
+ return hrefs;
121
+ }
122
+
123
+ export function CssVariables() {
124
+ const {css, links} = useMemo(
125
+ () => ({css: buildCss(), links: fontLinks()}),
126
+ [],
127
+ );
128
+ return (
129
+ <>
130
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
131
+ <link
132
+ rel="preconnect"
133
+ href="https://fonts.gstatic.com"
134
+ crossOrigin="anonymous"
135
+ />
136
+ {links.map((href) => (
137
+ <link key={href} rel="stylesheet" href={href} />
138
+ ))}
139
+ <style dangerouslySetInnerHTML={{__html: css}} />
140
+ </>
141
+ );
142
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Contexts giving ported sections access to Shopify data.
3
+ *
4
+ * - ThemeDataContext (set once in root/PageLayout): header menu, footer
5
+ * menu, cart promise, shop info — the mirror of Liquid globals like
6
+ * `linklists`, `cart`, `shop`.
7
+ * - TemplateContext (set per route): the route's resources (`product`,
8
+ * `collection`, …) — the mirror of Liquid template objects — plus
9
+ * per-section loader results.
10
+ * - SectionContext / current section id, used by useSectionData().
11
+ */
12
+ import {createContext, useContext} from 'react';
13
+ import type {ReactNode} from 'react';
14
+
15
+ export interface ThemeGlobals {
16
+ /** Root loader data from the Hydrogen scaffold (header/footer queries, cart…). */
17
+ header?: unknown;
18
+ footer?: unknown;
19
+ cart?: unknown;
20
+ isLoggedIn?: unknown;
21
+ publicStoreDomain?: string;
22
+ shop?: unknown;
23
+ }
24
+
25
+ const ThemeDataContext = createContext<ThemeGlobals>({});
26
+
27
+ export interface TemplateScope {
28
+ /** Template name, e.g. "index", "product". */
29
+ name: string;
30
+ /**
31
+ * Whether `name` is a page template or a section group — the scope
32
+ * half of a @zalify/storefront-kit/schemas ThemeJsonPath. Defaults to
33
+ * "template".
34
+ */
35
+ kind?: 'template' | 'group';
36
+ /** Route resources: product, collection, blog, article, page, searchResult… */
37
+ resources: Record<string, unknown>;
38
+ /** Results of app/sections/<type>.server.ts loaders, keyed by section key. */
39
+ sectionData: Record<string, unknown>;
40
+ }
41
+
42
+ const TemplateContext = createContext<TemplateScope>({
43
+ name: '',
44
+ resources: {},
45
+ sectionData: {},
46
+ });
47
+
48
+ const SectionContext = createContext<{id: string}>({id: ''});
49
+
50
+ export function ThemeDataProvider({
51
+ value,
52
+ children,
53
+ }: {
54
+ value: ThemeGlobals;
55
+ children: ReactNode;
56
+ }) {
57
+ return (
58
+ <ThemeDataContext.Provider value={value}>
59
+ {children}
60
+ </ThemeDataContext.Provider>
61
+ );
62
+ }
63
+
64
+ export function TemplateProvider({
65
+ value,
66
+ children,
67
+ }: {
68
+ value: TemplateScope;
69
+ children: ReactNode;
70
+ }) {
71
+ return (
72
+ <TemplateContext.Provider value={value}>
73
+ {children}
74
+ </TemplateContext.Provider>
75
+ );
76
+ }
77
+
78
+ export function SectionProvider({
79
+ id,
80
+ children,
81
+ }: {
82
+ id: string;
83
+ children: ReactNode;
84
+ }) {
85
+ return (
86
+ <SectionContext.Provider value={{id}}>{children}</SectionContext.Provider>
87
+ );
88
+ }
89
+
90
+ /** Liquid globals mirror (`cart`, `shop`, menus…). */
91
+ export function useThemeGlobals(): ThemeGlobals {
92
+ return useContext(ThemeDataContext);
93
+ }
94
+
95
+ /** Route resource mirror of Liquid template objects, e.g. useResource('product'). */
96
+ export function useResource<T = unknown>(name: string): T | undefined {
97
+ return useContext(TemplateContext).resources[name] as T | undefined;
98
+ }
99
+
100
+ export function useTemplateScope(): TemplateScope {
101
+ return useContext(TemplateContext);
102
+ }
103
+
104
+ /** Result of this section's optional .server.ts loader. */
105
+ export function useSectionData<T = unknown>(): T | undefined {
106
+ const {id} = useContext(SectionContext);
107
+ return useContext(TemplateContext).sectionData[id] as T | undefined;
108
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Font resolution for the mirror. The Liquid theme uses Shopify's
3
+ * font_picker (handles like "work_sans_n4") plus optional Google-font
4
+ * override text settings. Outside Shopify's font CDN we resolve picker
5
+ * handles to the same family served from Google Fonts, keeping the
6
+ * css-variables contract (--font-body--family etc.) identical.
7
+ */
8
+
9
+ export interface ResolvedFont {
10
+ family: string;
11
+ weight: number;
12
+ style: 'normal' | 'italic';
13
+ }
14
+
15
+ /** Parse a Shopify font handle: "work_sans_n4" → Work Sans / 400 / normal. */
16
+ export function parseFontHandle(handle: string | undefined): ResolvedFont {
17
+ const fallback: ResolvedFont = {
18
+ family: 'Work Sans',
19
+ weight: 400,
20
+ style: 'normal',
21
+ };
22
+ if (!handle) return fallback;
23
+ const match = /^(.*)_([nib])(\d)$/.exec(handle);
24
+ if (!match) return fallback;
25
+ const family = match[1]
26
+ .split('_')
27
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
28
+ .join(' ');
29
+ return {
30
+ family,
31
+ weight: Number(match[3]) * 100,
32
+ style: match[2] === 'i' ? 'italic' : 'normal',
33
+ };
34
+ }
35
+
36
+ export function googleFontsHref(
37
+ family: string,
38
+ axes = 'ital,wght@0,400;0,500;0,700;1,400',
39
+ ): string {
40
+ const param = family.trim().replace(/ /g, '+');
41
+ return `https://fonts.googleapis.com/css2?family=${param}:${axes}&display=swap`;
42
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Helpers mirroring the theme's image snippet / `image_url` usage for
3
+ * images that arrive from JSON settings (not the Storefront API — for
4
+ * those use @shopify/hydrogen's <Image>).
5
+ */
6
+
7
+ /**
8
+ * Resolve an image_picker setting value to a usable URL. Values saved
9
+ * by the theme editor look like "shopify://shop_images/foo.png", which
10
+ * only the Liquid runtime can resolve — those return null here so the
11
+ * component falls back to its placeholder (matching placeholder_svg_tag
12
+ * behavior on a fresh install).
13
+ */
14
+ export function settingImageUrl(value: unknown): string | null {
15
+ if (typeof value !== 'string' || !value) return null;
16
+ if (value.startsWith('shopify://')) return null;
17
+ return value;
18
+ }
19
+
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
+ }
26
+
27
+ /** Srcset across the theme's standard width ladder. */
28
+ export function imageSrcSet(
29
+ url: string,
30
+ widths: number[] = [400, 700, 1000, 1500],
31
+ ): string | undefined {
32
+ if (!url.includes('cdn.shopify.com')) return undefined;
33
+ return widths.map((w) => `${imageUrl(url, w)} ${w}w`).join(', ');
34
+ }
35
+
36
+ /**
37
+ * Neutral placeholder mirroring Liquid's placeholder_svg_tag: a muted
38
+ * box that inherits the component's sizing classes.
39
+ */
40
+ export function PlaceholderSvg({className}: {className?: string}) {
41
+ return (
42
+ <svg
43
+ className={className}
44
+ viewBox="0 0 525 525"
45
+ xmlns="http://www.w3.org/2000/svg"
46
+ role="img"
47
+ aria-hidden="true"
48
+ preserveAspectRatio="xMidYMid slice"
49
+ >
50
+ <rect width="525" height="525" fill="currentColor" opacity="0.06" />
51
+ <path
52
+ d="M262.5 138a52 52 0 1 1 0 104 52 52 0 0 1 0-104Zm-131 249c0-58 59-90 131-90s131 32 131 90v0H131.5v0Z"
53
+ fill="currentColor"
54
+ opacity="0.12"
55
+ />
56
+ </svg>
57
+ );
58
+ }