@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.
- package/LICENSE.md +49 -0
- package/README.md +63 -0
- package/dist/commerce/countdown.d.ts +18 -0
- package/dist/commerce/countdown.js +28 -0
- package/dist/commerce/events.d.ts +29 -0
- package/dist/commerce/events.js +14 -0
- package/dist/commerce/facets.d.ts +27 -0
- package/dist/commerce/facets.js +71 -0
- package/dist/commerce/index.d.ts +11 -0
- package/dist/commerce/index.js +11 -0
- package/dist/commerce/money.d.ts +51 -0
- package/dist/commerce/money.js +83 -0
- package/dist/commerce/product.d.ts +102 -0
- package/dist/commerce/product.js +93 -0
- package/dist/editor/frame.d.ts +57 -0
- package/dist/editor/frame.js +241 -0
- package/dist/editor/host.d.ts +31 -0
- package/dist/editor/host.js +67 -0
- package/dist/editor/index.d.ts +9 -0
- package/dist/editor/index.js +9 -0
- package/dist/react/adapter.d.ts +49 -0
- package/dist/react/adapter.js +23 -0
- package/dist/react/blocks/_box.d.ts +10 -0
- package/dist/react/blocks/_box.js +17 -0
- package/dist/react/blocks/_slide.d.ts +24 -0
- package/dist/react/blocks/_slide.js +21 -0
- package/dist/react/blocks/_video-card.d.ts +11 -0
- package/dist/react/blocks/_video-card.js +39 -0
- package/dist/react/blocks/accordion.d.ts +12 -0
- package/dist/react/blocks/accordion.js +5 -0
- package/dist/react/blocks/button.d.ts +10 -0
- package/dist/react/blocks/button.js +15 -0
- package/dist/react/blocks/countdown.d.ts +9 -0
- package/dist/react/blocks/countdown.js +64 -0
- package/dist/react/blocks/custom-liquid.d.ts +11 -0
- package/dist/react/blocks/custom-liquid.js +4 -0
- package/dist/react/blocks/group.d.ts +9 -0
- package/dist/react/blocks/group.js +8 -0
- package/dist/react/blocks/image.d.ts +8 -0
- package/dist/react/blocks/image.js +7 -0
- package/dist/react/blocks/message.d.ts +14 -0
- package/dist/react/blocks/message.js +5 -0
- package/dist/react/blocks/quote.d.ts +9 -0
- package/dist/react/blocks/quote.js +5 -0
- package/dist/react/blocks/spacer.d.ts +8 -0
- package/dist/react/blocks/spacer.js +5 -0
- package/dist/react/blocks/story.d.ts +17 -0
- package/dist/react/blocks/story.js +7 -0
- package/dist/react/blocks/text.d.ts +9 -0
- package/dist/react/blocks/text.js +5 -0
- package/dist/react/blocks/video.d.ts +17 -0
- package/dist/react/blocks/video.js +56 -0
- package/dist/react/components/Facets.d.ts +21 -0
- package/dist/react/components/Facets.js +220 -0
- package/dist/react/components/Icon.d.ts +12 -0
- package/dist/react/components/Icon.js +66 -0
- package/dist/react/components/PaymentIcons.d.ts +5 -0
- package/dist/react/components/PaymentIcons.generated.d.ts +22 -0
- package/dist/react/components/PaymentIcons.generated.js +21 -0
- package/dist/react/components/PaymentIcons.js +18 -0
- package/dist/react/components/Price.d.ts +24 -0
- package/dist/react/components/Price.js +9 -0
- package/dist/react/components/ProductBadges.d.ts +24 -0
- package/dist/react/components/ProductBadges.js +57 -0
- package/dist/react/components/ProductCard.d.ts +17 -0
- package/dist/react/components/ProductCard.js +202 -0
- package/dist/react/components/Rating.d.ts +11 -0
- package/dist/react/components/Rating.js +35 -0
- package/dist/react/components/SwatchStyle.d.ts +23 -0
- package/dist/react/components/SwatchStyle.js +22 -0
- package/dist/react/components/VideoModal.d.ts +55 -0
- package/dist/react/components/VideoModal.js +102 -0
- package/dist/react/engine/CssVariables.d.ts +1 -0
- package/dist/react/engine/CssVariables.js +105 -0
- package/dist/react/engine/context.d.ts +43 -0
- package/dist/react/engine/context.js +45 -0
- package/dist/react/engine/fonts.d.ts +15 -0
- package/dist/react/engine/fonts.js +33 -0
- package/dist/react/engine/images.d.ts +24 -0
- package/dist/react/engine/images.js +40 -0
- package/dist/react/engine/render.d.ts +29 -0
- package/dist/react/engine/render.js +87 -0
- package/dist/react/engine/settings.d.ts +8 -0
- package/dist/react/engine/settings.js +68 -0
- package/dist/react/engine/store.d.ts +64 -0
- package/dist/react/engine/store.js +42 -0
- package/dist/react/engine/translate.d.ts +7 -0
- package/dist/react/engine/translate.js +33 -0
- package/dist/react/engine/types.d.ts +128 -0
- package/dist/react/engine/types.js +1 -0
- package/dist/react/index.d.ts +38 -0
- package/dist/react/index.js +39 -0
- package/dist/react/registries.d.ts +10 -0
- package/dist/react/registries.js +68 -0
- package/dist/react/registries.server.d.ts +2 -0
- package/dist/react/registries.server.js +98 -0
- package/dist/react/sections/404.d.ts +10 -0
- package/dist/react/sections/404.js +10 -0
- package/dist/react/sections/announcement-bar.d.ts +14 -0
- package/dist/react/sections/announcement-bar.js +84 -0
- package/dist/react/sections/article.d.ts +17 -0
- package/dist/react/sections/article.js +31 -0
- package/dist/react/sections/contact-form.d.ts +16 -0
- package/dist/react/sections/contact-form.js +8 -0
- package/dist/react/sections/custom-liquid.d.ts +14 -0
- package/dist/react/sections/custom-liquid.js +7 -0
- package/dist/react/sections/custom-section.d.ts +14 -0
- package/dist/react/sections/custom-section.js +9 -0
- package/dist/react/sections/featured-collection.d.ts +10 -0
- package/dist/react/sections/featured-collection.js +23 -0
- package/dist/react/sections/hero.d.ts +6 -0
- package/dist/react/sections/hero.js +35 -0
- package/dist/react/sections/image-with-text.d.ts +13 -0
- package/dist/react/sections/image-with-text.js +9 -0
- package/dist/react/sections/multicolumn.d.ts +15 -0
- package/dist/react/sections/multicolumn.js +8 -0
- package/dist/react/sections/page.d.ts +13 -0
- package/dist/react/sections/page.js +11 -0
- package/dist/react/sections/password.d.ts +15 -0
- package/dist/react/sections/password.js +9 -0
- package/dist/react/sections/rich-text.d.ts +12 -0
- package/dist/react/sections/rich-text.js +7 -0
- package/dist/react/sections/stories.d.ts +18 -0
- package/dist/react/sections/stories.js +10 -0
- package/dist/react/sections/video-bubble.d.ts +13 -0
- package/dist/react/sections/video-bubble.js +54 -0
- package/dist/react/sections/video-carousel.d.ts +19 -0
- package/dist/react/sections/video-carousel.js +9 -0
- package/dist/react/server.d.ts +7 -0
- package/dist/react/server.js +42 -0
- package/dist/schemas/bridge.d.ts +98 -0
- package/dist/schemas/bridge.js +50 -0
- package/dist/schemas/data.d.ts +54 -0
- package/dist/schemas/data.js +22 -0
- package/dist/schemas/index.d.ts +14 -0
- package/dist/schemas/index.js +14 -0
- package/dist/schemas/manifest.d.ts +63 -0
- package/dist/schemas/manifest.js +32 -0
- package/dist/schemas/schema.d.ts +159 -0
- package/dist/schemas/schema.js +129 -0
- package/dist/schemas/validate.d.ts +22 -0
- package/dist/schemas/validate.js +199 -0
- package/package.json +84 -0
- package/src/commerce/countdown.ts +38 -0
- package/src/commerce/events.ts +43 -0
- package/src/commerce/facets.ts +88 -0
- package/src/commerce/index.ts +11 -0
- package/src/commerce/money.ts +120 -0
- package/src/commerce/product.ts +177 -0
- package/src/editor/frame.ts +305 -0
- package/src/editor/host.ts +101 -0
- package/src/editor/index.ts +9 -0
- package/src/react/adapter.tsx +83 -0
- package/src/react/blocks/_box.tsx +37 -0
- package/src/react/blocks/_slide.tsx +96 -0
- package/src/react/blocks/_video-card.tsx +131 -0
- package/src/react/blocks/accordion.tsx +33 -0
- package/src/react/blocks/button.tsx +36 -0
- package/src/react/blocks/countdown.tsx +105 -0
- package/src/react/blocks/custom-liquid.tsx +21 -0
- package/src/react/blocks/group.tsx +32 -0
- package/src/react/blocks/image.tsx +29 -0
- package/src/react/blocks/message.tsx +28 -0
- package/src/react/blocks/quote.tsx +24 -0
- package/src/react/blocks/spacer.tsx +17 -0
- package/src/react/blocks/story.tsx +40 -0
- package/src/react/blocks/text.tsx +24 -0
- package/src/react/blocks/video.tsx +132 -0
- package/src/react/components/Facets.tsx +424 -0
- package/src/react/components/Icon.tsx +96 -0
- package/src/react/components/PaymentIcons.generated.ts +24 -0
- package/src/react/components/PaymentIcons.tsx +27 -0
- package/src/react/components/Price.tsx +72 -0
- package/src/react/components/ProductBadges.tsx +95 -0
- package/src/react/components/ProductCard.tsx +417 -0
- package/src/react/components/Rating.tsx +59 -0
- package/src/react/components/SwatchStyle.tsx +50 -0
- package/src/react/components/VideoModal.tsx +236 -0
- package/src/react/engine/CssVariables.tsx +142 -0
- package/src/react/engine/context.tsx +108 -0
- package/src/react/engine/fonts.ts +42 -0
- package/src/react/engine/images.tsx +58 -0
- package/src/react/engine/render.tsx +190 -0
- package/src/react/engine/settings.ts +89 -0
- package/src/react/engine/store.ts +103 -0
- package/src/react/engine/translate.ts +40 -0
- package/src/react/engine/types.ts +142 -0
- package/src/react/index.ts +78 -0
- package/src/react/registries.server.ts +110 -0
- package/src/react/registries.ts +80 -0
- package/src/react/sections/404.tsx +37 -0
- package/src/react/sections/announcement-bar.tsx +170 -0
- package/src/react/sections/article.tsx +97 -0
- package/src/react/sections/contact-form.tsx +104 -0
- package/src/react/sections/custom-liquid.tsx +39 -0
- package/src/react/sections/custom-section.tsx +44 -0
- package/src/react/sections/featured-collection.tsx +89 -0
- package/src/react/sections/hero.tsx +84 -0
- package/src/react/sections/image-with-text.tsx +61 -0
- package/src/react/sections/multicolumn.tsx +46 -0
- package/src/react/sections/page.tsx +43 -0
- package/src/react/sections/password.tsx +60 -0
- package/src/react/sections/rich-text.tsx +37 -0
- package/src/react/sections/stories.tsx +51 -0
- package/src/react/sections/video-bubble.tsx +145 -0
- package/src/react/sections/video-carousel.tsx +57 -0
- package/src/react/server.ts +60 -0
- package/src/schemas/bridge.ts +143 -0
- package/src/schemas/data.ts +72 -0
- package/src/schemas/index.ts +14 -0
- package/src/schemas/manifest.ts +85 -0
- package/src/schemas/schema.ts +287 -0
- 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
|
+
}
|