@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
package/LICENSE.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Zalify Source Available License 1.0
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zalify, Inc. All rights reserved.
|
|
4
|
+
|
|
5
|
+
## Grant
|
|
6
|
+
|
|
7
|
+
Subject to the terms below, Zalify grants you a non-exclusive,
|
|
8
|
+
worldwide, non-transferable, royalty-free license to use, copy, and
|
|
9
|
+
modify this software, and to deploy it as part of storefronts, websites,
|
|
10
|
+
and applications that you build and operate for yourself or for your
|
|
11
|
+
clients.
|
|
12
|
+
|
|
13
|
+
Code scaffolded into your project by Zalify tooling (for example by
|
|
14
|
+
`create-zalify-theme`) may be modified without restriction and remains
|
|
15
|
+
governed by this license as part of your project.
|
|
16
|
+
|
|
17
|
+
## Limitations
|
|
18
|
+
|
|
19
|
+
You may not:
|
|
20
|
+
|
|
21
|
+
1. Redistribute, sublicense, sell, or publish this software or
|
|
22
|
+
substantial portions of it — on its own or as part of a template,
|
|
23
|
+
starter, theme, SDK, or similar developer product;
|
|
24
|
+
2. Use this software to build a product or service that competes with
|
|
25
|
+
Zalify, including theme marketplaces, storefront builders, or visual
|
|
26
|
+
editors;
|
|
27
|
+
3. Remove or alter copyright, license, or attribution notices;
|
|
28
|
+
4. Use Zalify's names, logos, or trademarks except to describe your
|
|
29
|
+
permitted use of the software.
|
|
30
|
+
|
|
31
|
+
Running the storefronts you build with this software — including
|
|
32
|
+
hosting them commercially for yourself or your clients — is expressly
|
|
33
|
+
permitted and is not "competing with Zalify."
|
|
34
|
+
|
|
35
|
+
## Termination
|
|
36
|
+
|
|
37
|
+
This license terminates automatically if you violate its terms. Upon
|
|
38
|
+
termination you must stop distributing the software, but storefronts
|
|
39
|
+
already deployed under a compliant use may continue to operate.
|
|
40
|
+
|
|
41
|
+
## Disclaimer
|
|
42
|
+
|
|
43
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
44
|
+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
45
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
46
|
+
IN NO EVENT SHALL ZALIFY BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
47
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
48
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
49
|
+
DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# @zalify/storefront-kit
|
|
2
|
+
|
|
3
|
+
The Zalify storefront SDK: everything shared between Zalify storefronts,
|
|
4
|
+
shipped as one versioned package. Scaffold a storefront with
|
|
5
|
+
[`create-zalify-theme`](https://www.npmjs.com/package/create-zalify-theme)
|
|
6
|
+
and upgrade this package to pull in new engine features, sections, and
|
|
7
|
+
blocks — your customizations live in your project, not in a fork
|
|
8
|
+
(the same model as the Shopify Hydrogen SDK).
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pnpm create zalify-theme@latest
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Subpath exports
|
|
15
|
+
|
|
16
|
+
| Import | What's inside |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| `@zalify/storefront-kit/commerce` | Framework-agnostic commerce logic: money & unit-price formatting, the cart event-bus contract, countdown math, the facets URL codec, Storefront-API product helpers. Pure TypeScript — no DOM, no React. |
|
|
19
|
+
| `@zalify/storefront-kit/schemas` | The theme contract: Shopify OS 2.0-aligned theme JSON data types (templates, sections, blocks), section/block schema types, the editor manifest format, the canvas-editor bridge protocol, and validators for all of them. |
|
|
20
|
+
| `@zalify/storefront-kit/editor` | The canvas-editor bridge — postMessage runtime for editing a running storefront across origins. `/editor/frame` mounts inside the storefront preview (selection hit-testing, highlights, optimistic template apply); `/editor/host` drives the preview iframe from the editor. |
|
|
21
|
+
| `@zalify/storefront-kit/react` | The theme engine + shared React components: `installTheme`, `<ThemeTemplate>`/`<SectionGroup>` renderers, the settings→CSS pipeline, built-in section/block registries, and components like `ProductCard`, `Price`, `Facets`. |
|
|
22
|
+
| `@zalify/storefront-kit/react/server` | Server side of the section engine: per-section-type data loaders (`loadSectionData`, `builtinSectionLoaders`) for route loaders / server components. |
|
|
23
|
+
|
|
24
|
+
React is an **optional** peer dependency — only the `/react` subpaths
|
|
25
|
+
need it (a Liquid theme consuming `/commerce` installs nothing else).
|
|
26
|
+
|
|
27
|
+
## How an app uses the engine
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
// 1. Register the theme once at startup, merging the built-in
|
|
31
|
+
// sections/blocks with your app-local ones:
|
|
32
|
+
import {installTheme, builtinSections, builtinBlocks} from '@zalify/storefront-kit/react';
|
|
33
|
+
installTheme({
|
|
34
|
+
schema, // your theme's JSON data
|
|
35
|
+
sections: {...builtinSections, ...appSections}, // extend, don't fork
|
|
36
|
+
blocks: {...builtinBlocks, ...appBlocks},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// 2. Inject your framework's router/cart primitives through the
|
|
40
|
+
// adapter seam:
|
|
41
|
+
import {AdapterProvider} from '@zalify/storefront-kit/react';
|
|
42
|
+
<AdapterProvider adapter={myAdapter}>{children}</AdapterProvider>;
|
|
43
|
+
|
|
44
|
+
// 3. Render pages from theme JSON:
|
|
45
|
+
import {ThemeTemplate} from '@zalify/storefront-kit/react';
|
|
46
|
+
<ThemeTemplate name="product" resources={resources} sectionData={data} />;
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Customization happens by registering your own sections/blocks and
|
|
50
|
+
editing your project's theme JSON — not by patching this package. That
|
|
51
|
+
is what keeps `pnpm up @zalify/storefront-kit` a safe operation.
|
|
52
|
+
|
|
53
|
+
## Versioning
|
|
54
|
+
|
|
55
|
+
Pre-1.0: minor versions may contain breaking changes; patch versions are
|
|
56
|
+
safe. The package ships compiled ESM + type declarations (`dist/`) with
|
|
57
|
+
the TypeScript source included (`src/`) for reference and sourcemaps.
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
Source-available under the [Zalify Source Available License](./LICENSE.md):
|
|
62
|
+
build, modify, and operate storefronts freely (including for clients);
|
|
63
|
+
don't redistribute the SDK or use it to build competing theme products.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Countdown math shared by the theme's <countdown-timer> island and the
|
|
3
|
+
* React countdown block. Rendering (digit slots, hidden flips) stays in
|
|
4
|
+
* each runtime; only the clock logic lives here.
|
|
5
|
+
*/
|
|
6
|
+
/** Tolerant end-time parse — accepts ISO and "2026/08/01 00:00:00+0800". */
|
|
7
|
+
export declare function parseEndTime(raw: string): number;
|
|
8
|
+
/** Whole seconds remaining until `end` (epoch ms), floored at 0. */
|
|
9
|
+
export declare function secondsUntil(end: number, now?: number): number;
|
|
10
|
+
export interface CountdownParts {
|
|
11
|
+
days: number;
|
|
12
|
+
hours: number;
|
|
13
|
+
minutes: number;
|
|
14
|
+
seconds: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function splitCountdown(remainSeconds: number): CountdownParts;
|
|
17
|
+
/** Two-digit display padding ("07"). */
|
|
18
|
+
export declare function pad2(value: number): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Countdown math shared by the theme's <countdown-timer> island and the
|
|
3
|
+
* React countdown block. Rendering (digit slots, hidden flips) stays in
|
|
4
|
+
* each runtime; only the clock logic lives here.
|
|
5
|
+
*/
|
|
6
|
+
/** Tolerant end-time parse — accepts ISO and "2026/08/01 00:00:00+0800". */
|
|
7
|
+
export function parseEndTime(raw) {
|
|
8
|
+
const direct = Date.parse(raw);
|
|
9
|
+
if (!Number.isNaN(direct))
|
|
10
|
+
return direct;
|
|
11
|
+
return Date.parse(raw.replaceAll('/', '-').replace(' ', 'T'));
|
|
12
|
+
}
|
|
13
|
+
/** Whole seconds remaining until `end` (epoch ms), floored at 0. */
|
|
14
|
+
export function secondsUntil(end, now = Date.now()) {
|
|
15
|
+
return Math.max(0, Math.floor((end - now) / 1000));
|
|
16
|
+
}
|
|
17
|
+
export function splitCountdown(remainSeconds) {
|
|
18
|
+
return {
|
|
19
|
+
days: Math.floor(remainSeconds / 86400),
|
|
20
|
+
hours: Math.floor(remainSeconds / 3600) % 24,
|
|
21
|
+
minutes: Math.floor(remainSeconds / 60) % 60,
|
|
22
|
+
seconds: remainSeconds % 60,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Two-digit display padding ("07"). */
|
|
26
|
+
export function pad2(value) {
|
|
27
|
+
return String(value).padStart(2, '0');
|
|
28
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed pub/sub bus for cross-component state — THE event contract
|
|
3
|
+
* every storefront implementation speaks (the Liquid theme's islands
|
|
4
|
+
* and the React apps use the same names and payloads).
|
|
5
|
+
*
|
|
6
|
+
* Add new event names + payload shapes to ThemeEvents; both emit() and
|
|
7
|
+
* on() are checked against it. Keep payloads small and serializable —
|
|
8
|
+
* components should re-read the DOM or refetch, not sync big state here.
|
|
9
|
+
*/
|
|
10
|
+
import type { CardProduct } from './product';
|
|
11
|
+
export interface ThemeEvents {
|
|
12
|
+
'cart:updated': {
|
|
13
|
+
itemCount: number;
|
|
14
|
+
sections?: Record<string, string>;
|
|
15
|
+
};
|
|
16
|
+
'cart:error': {
|
|
17
|
+
message: string;
|
|
18
|
+
};
|
|
19
|
+
/** React runtimes: open the quick-add dialog for a product.
|
|
20
|
+
(The Liquid theme reaches its <dialog> directly instead.) */
|
|
21
|
+
'quick-add:open': {
|
|
22
|
+
product: CardProduct;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export declare function emit<K extends keyof ThemeEvents>(type: K, detail: ThemeEvents[K]): void;
|
|
26
|
+
/** Subscribe. Returns an unsubscribe function; also accepts an AbortSignal. */
|
|
27
|
+
export declare function on<K extends keyof ThemeEvents>(type: K, handler: (detail: ThemeEvents[K]) => void, options?: {
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
}): () => void;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* SSR-safe: EventTarget exists in workers/Node 18+/browsers, but guard
|
|
2
|
+
anyway so importing this module can never throw at load time. */
|
|
3
|
+
const bus = typeof EventTarget === 'undefined' ? null : new EventTarget();
|
|
4
|
+
export function emit(type, detail) {
|
|
5
|
+
bus?.dispatchEvent(new CustomEvent(type, { detail }));
|
|
6
|
+
}
|
|
7
|
+
/** Subscribe. Returns an unsubscribe function; also accepts an AbortSignal. */
|
|
8
|
+
export function on(type, handler, options) {
|
|
9
|
+
if (!bus)
|
|
10
|
+
return () => { };
|
|
11
|
+
const listener = (event) => handler(event.detail);
|
|
12
|
+
bus.addEventListener(type, listener, options);
|
|
13
|
+
return () => bus.removeEventListener(type, listener);
|
|
14
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Facets (filter/sort) URL codec shared by every storefront:
|
|
3
|
+
*
|
|
4
|
+
* - cleanFormParams: form entries → clean query params (empty values
|
|
5
|
+
* dropped) — the theme island's #formUrl and the React Facets form
|
|
6
|
+
* both build their navigation URLs with it.
|
|
7
|
+
* - parseFilters/parseSort/parseSearchSort: URL → Storefront API
|
|
8
|
+
* query inputs, used by the React apps' route loaders.
|
|
9
|
+
*
|
|
10
|
+
* URL contract (Hydrogen-skeleton style):
|
|
11
|
+
* sort_by=<handle> theme sort handle
|
|
12
|
+
* filter.<key>=<json> one param per applied Storefront filter
|
|
13
|
+
* e.g. filter.variantOption={"name":"color","value":"Red"}
|
|
14
|
+
* filter.price={"min":10,"max":50}
|
|
15
|
+
*/
|
|
16
|
+
/** Form entries → query params, dropping empty values. */
|
|
17
|
+
export declare function cleanFormParams(entries: Iterable<[string, FormDataEntryValue | string]>): URLSearchParams;
|
|
18
|
+
export type ProductFilterInput = Record<string, unknown>;
|
|
19
|
+
export declare function parseFilters(searchParams: URLSearchParams): ProductFilterInput[];
|
|
20
|
+
export interface SortSelection {
|
|
21
|
+
sortKey: string;
|
|
22
|
+
reverse: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** Collection pages: theme sort handle → ProductCollectionSortKeys. */
|
|
25
|
+
export declare function parseSort(searchParams: URLSearchParams): SortSelection;
|
|
26
|
+
/** Search page: theme sort handle → SearchSortKeys. */
|
|
27
|
+
export declare function parseSearchSort(searchParams: URLSearchParams): SortSelection;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Facets (filter/sort) URL codec shared by every storefront:
|
|
3
|
+
*
|
|
4
|
+
* - cleanFormParams: form entries → clean query params (empty values
|
|
5
|
+
* dropped) — the theme island's #formUrl and the React Facets form
|
|
6
|
+
* both build their navigation URLs with it.
|
|
7
|
+
* - parseFilters/parseSort/parseSearchSort: URL → Storefront API
|
|
8
|
+
* query inputs, used by the React apps' route loaders.
|
|
9
|
+
*
|
|
10
|
+
* URL contract (Hydrogen-skeleton style):
|
|
11
|
+
* sort_by=<handle> theme sort handle
|
|
12
|
+
* filter.<key>=<json> one param per applied Storefront filter
|
|
13
|
+
* e.g. filter.variantOption={"name":"color","value":"Red"}
|
|
14
|
+
* filter.price={"min":10,"max":50}
|
|
15
|
+
*/
|
|
16
|
+
/** Form entries → query params, dropping empty values. */
|
|
17
|
+
export function cleanFormParams(entries) {
|
|
18
|
+
const params = new URLSearchParams();
|
|
19
|
+
for (const [name, value] of entries) {
|
|
20
|
+
if (typeof value === 'string' && value !== '')
|
|
21
|
+
params.append(name, value);
|
|
22
|
+
}
|
|
23
|
+
return params;
|
|
24
|
+
}
|
|
25
|
+
export function parseFilters(searchParams) {
|
|
26
|
+
const filters = [];
|
|
27
|
+
for (const [param, raw] of searchParams.entries()) {
|
|
28
|
+
if (!param.startsWith('filter.'))
|
|
29
|
+
continue;
|
|
30
|
+
const key = param.slice('filter.'.length);
|
|
31
|
+
if (!key)
|
|
32
|
+
continue;
|
|
33
|
+
let value = raw;
|
|
34
|
+
try {
|
|
35
|
+
value = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// plain-string filter values (e.g. tag) pass through as-is
|
|
39
|
+
}
|
|
40
|
+
filters.push({ [key]: value });
|
|
41
|
+
}
|
|
42
|
+
return filters;
|
|
43
|
+
}
|
|
44
|
+
const DEFAULT_SORT = { sortKey: 'MANUAL', reverse: false };
|
|
45
|
+
const SORT_MAP = {
|
|
46
|
+
manual: DEFAULT_SORT,
|
|
47
|
+
'best-selling': { sortKey: 'BEST_SELLING', reverse: false },
|
|
48
|
+
'title-ascending': { sortKey: 'TITLE', reverse: false },
|
|
49
|
+
'title-descending': { sortKey: 'TITLE', reverse: true },
|
|
50
|
+
'price-ascending': { sortKey: 'PRICE', reverse: false },
|
|
51
|
+
'price-descending': { sortKey: 'PRICE', reverse: true },
|
|
52
|
+
'created-ascending': { sortKey: 'CREATED', reverse: false },
|
|
53
|
+
'created-descending': { sortKey: 'CREATED', reverse: true },
|
|
54
|
+
};
|
|
55
|
+
/** Collection pages: theme sort handle → ProductCollectionSortKeys. */
|
|
56
|
+
export function parseSort(searchParams) {
|
|
57
|
+
return SORT_MAP[searchParams.get('sort_by') ?? ''] ?? DEFAULT_SORT;
|
|
58
|
+
}
|
|
59
|
+
const DEFAULT_SEARCH_SORT = {
|
|
60
|
+
sortKey: 'RELEVANCE',
|
|
61
|
+
reverse: false,
|
|
62
|
+
};
|
|
63
|
+
const SEARCH_SORT_MAP = {
|
|
64
|
+
relevance: DEFAULT_SEARCH_SORT,
|
|
65
|
+
'price-ascending': { sortKey: 'PRICE', reverse: false },
|
|
66
|
+
'price-descending': { sortKey: 'PRICE', reverse: true },
|
|
67
|
+
};
|
|
68
|
+
/** Search page: theme sort handle → SearchSortKeys. */
|
|
69
|
+
export function parseSearchSort(searchParams) {
|
|
70
|
+
return (SEARCH_SORT_MAP[searchParams.get('sort_by') ?? ''] ?? DEFAULT_SEARCH_SORT);
|
|
71
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zalify/storefront-kit/commerce — framework-agnostic commerce logic shared by
|
|
3
|
+
* every Zalify storefront (Liquid theme islands, Hydrogen, Next.js…).
|
|
4
|
+
* Pure TypeScript: no DOM assumptions beyond web-standard globals, no
|
|
5
|
+
* React. Consumers bundle it as source.
|
|
6
|
+
*/
|
|
7
|
+
export * from './money';
|
|
8
|
+
export * from './events';
|
|
9
|
+
export * from './countdown';
|
|
10
|
+
export * from './facets';
|
|
11
|
+
export * from './product';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zalify/storefront-kit/commerce — framework-agnostic commerce logic shared by
|
|
3
|
+
* every Zalify storefront (Liquid theme islands, Hydrogen, Next.js…).
|
|
4
|
+
* Pure TypeScript: no DOM assumptions beyond web-standard globals, no
|
|
5
|
+
* React. Consumers bundle it as source.
|
|
6
|
+
*/
|
|
7
|
+
export * from './money';
|
|
8
|
+
export * from './events';
|
|
9
|
+
export * from './countdown';
|
|
10
|
+
export * from './facets';
|
|
11
|
+
export * from './product';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Money formatting for both storefront runtimes:
|
|
3
|
+
*
|
|
4
|
+
* - The Liquid theme's islands receive integer cents plus the shop's
|
|
5
|
+
* money format string (window.Theme.moneyFormat) → formatMoneyCents.
|
|
6
|
+
* - React apps receive Storefront API Money objects → formatMoney.
|
|
7
|
+
*/
|
|
8
|
+
/** Money shape from the Storefront API. */
|
|
9
|
+
export interface Money {
|
|
10
|
+
amount: string;
|
|
11
|
+
currencyCode: string;
|
|
12
|
+
}
|
|
13
|
+
export interface UnitPriceMeasurement {
|
|
14
|
+
referenceValue?: number | null;
|
|
15
|
+
referenceUnit?: string | null;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Format integer cents using a Shopify money format string
|
|
19
|
+
* (e.g. "${{amount}}") — mirror of Liquid's `| money` for client JS.
|
|
20
|
+
*/
|
|
21
|
+
export declare function formatMoneyCents(cents: number, format: string): string;
|
|
22
|
+
/** Format a Storefront API Money via Intl (drops .00 like the theme). */
|
|
23
|
+
export declare function formatMoney(money: Money | null | undefined): string;
|
|
24
|
+
export declare function isOnSale(price: Money | null | undefined, compareAt: Money | null | undefined): boolean;
|
|
25
|
+
/** A discount allocation as the Storefront API returns it. */
|
|
26
|
+
export interface DiscountAllocation {
|
|
27
|
+
/** CartCodeDiscountAllocation */
|
|
28
|
+
code?: string | null;
|
|
29
|
+
/** CartAutomaticDiscountAllocation / CartCustomDiscountAllocation */
|
|
30
|
+
title?: string | null;
|
|
31
|
+
discountedAmount?: Money | null;
|
|
32
|
+
}
|
|
33
|
+
export interface AggregatedDiscount {
|
|
34
|
+
label: string;
|
|
35
|
+
amount: Money;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Group Storefront API discount allocations by code/title and sum
|
|
39
|
+
* their amounts. The API returns cart-level `discountAllocations` as
|
|
40
|
+
* one entry per line the discount applies to (an order discount over
|
|
41
|
+
* four lines yields four allocations of the same code) — Liquid's
|
|
42
|
+
* `cart_level_discount_applications` comes pre-aggregated, and
|
|
43
|
+
* checkout shows one summed row per code. This is the React apps'
|
|
44
|
+
* equivalent for the cart/drawer summary rows.
|
|
45
|
+
*/
|
|
46
|
+
export declare function aggregateDiscountAllocations(allocations: ReadonlyArray<DiscountAllocation> | null | undefined): AggregatedDiscount[];
|
|
47
|
+
/**
|
|
48
|
+
* Mirror of Liquid's `unit_price_with_measurement` the way the theme's
|
|
49
|
+
* own JS formats it: "$4.99/100ml".
|
|
50
|
+
*/
|
|
51
|
+
export declare function formatUnitPrice(unitPrice: Money, measurement?: UnitPriceMeasurement | null): string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Money formatting for both storefront runtimes:
|
|
3
|
+
*
|
|
4
|
+
* - The Liquid theme's islands receive integer cents plus the shop's
|
|
5
|
+
* money format string (window.Theme.moneyFormat) → formatMoneyCents.
|
|
6
|
+
* - React apps receive Storefront API Money objects → formatMoney.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Format integer cents using a Shopify money format string
|
|
10
|
+
* (e.g. "${{amount}}") — mirror of Liquid's `| money` for client JS.
|
|
11
|
+
*/
|
|
12
|
+
export function formatMoneyCents(cents, format) {
|
|
13
|
+
const amount = cents / 100;
|
|
14
|
+
return format.replace(/\{\{\s*(\w+)\s*\}\}/, (_, key) => {
|
|
15
|
+
switch (key) {
|
|
16
|
+
case 'amount_no_decimals':
|
|
17
|
+
return String(Math.round(amount));
|
|
18
|
+
case 'amount_with_comma_separator':
|
|
19
|
+
return amount.toFixed(2).replace('.', ',');
|
|
20
|
+
case 'amount_no_decimals_with_comma_separator':
|
|
21
|
+
return String(Math.round(amount));
|
|
22
|
+
default:
|
|
23
|
+
return amount.toFixed(2);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** Format a Storefront API Money via Intl (drops .00 like the theme). */
|
|
28
|
+
export function formatMoney(money) {
|
|
29
|
+
if (!money)
|
|
30
|
+
return '';
|
|
31
|
+
const amount = Number(money.amount);
|
|
32
|
+
try {
|
|
33
|
+
return new Intl.NumberFormat('en', {
|
|
34
|
+
style: 'currency',
|
|
35
|
+
currency: money.currencyCode,
|
|
36
|
+
minimumFractionDigits: amount % 1 === 0 ? 0 : 2,
|
|
37
|
+
}).format(amount);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return `${money.currencyCode} ${money.amount}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function isOnSale(price, compareAt) {
|
|
44
|
+
if (!price || !compareAt)
|
|
45
|
+
return false;
|
|
46
|
+
return Number(compareAt.amount) > Number(price.amount);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Group Storefront API discount allocations by code/title and sum
|
|
50
|
+
* their amounts. The API returns cart-level `discountAllocations` as
|
|
51
|
+
* one entry per line the discount applies to (an order discount over
|
|
52
|
+
* four lines yields four allocations of the same code) — Liquid's
|
|
53
|
+
* `cart_level_discount_applications` comes pre-aggregated, and
|
|
54
|
+
* checkout shows one summed row per code. This is the React apps'
|
|
55
|
+
* equivalent for the cart/drawer summary rows.
|
|
56
|
+
*/
|
|
57
|
+
export function aggregateDiscountAllocations(allocations) {
|
|
58
|
+
const byLabel = new Map();
|
|
59
|
+
for (const allocation of allocations ?? []) {
|
|
60
|
+
const label = allocation.code ?? allocation.title ?? '';
|
|
61
|
+
const amount = Number(allocation.discountedAmount?.amount ?? 0);
|
|
62
|
+
const currencyCode = allocation.discountedAmount?.currencyCode ?? '';
|
|
63
|
+
const entry = byLabel.get(label);
|
|
64
|
+
if (entry)
|
|
65
|
+
entry.total += amount;
|
|
66
|
+
else
|
|
67
|
+
byLabel.set(label, { label, total: amount, currencyCode });
|
|
68
|
+
}
|
|
69
|
+
return [...byLabel.values()].map(({ label, total, currencyCode }) => ({
|
|
70
|
+
label,
|
|
71
|
+
amount: { amount: total.toFixed(2), currencyCode },
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Mirror of Liquid's `unit_price_with_measurement` the way the theme's
|
|
76
|
+
* own JS formats it: "$4.99/100ml".
|
|
77
|
+
*/
|
|
78
|
+
export function formatUnitPrice(unitPrice, measurement) {
|
|
79
|
+
const referenceValue = measurement?.referenceValue ?? 1;
|
|
80
|
+
const referenceUnit = (measurement?.referenceUnit ?? '').toLowerCase();
|
|
81
|
+
const reference = (referenceValue !== 1 ? String(referenceValue) : '') + referenceUnit;
|
|
82
|
+
return `${formatMoney(unitPrice)}/${reference}`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical product data model (Storefront API camelCase shape) and the
|
|
3
|
+
* pure helpers the React storefronts share. The Liquid theme does this
|
|
4
|
+
* work in Liquid itself — these mirror those template rules 1:1, so a
|
|
5
|
+
* behavior fix lands here and every React target inherits it.
|
|
6
|
+
*/
|
|
7
|
+
import type { Money, UnitPriceMeasurement } from './money';
|
|
8
|
+
export interface CardImage {
|
|
9
|
+
id?: string | null;
|
|
10
|
+
url: string;
|
|
11
|
+
altText?: string | null;
|
|
12
|
+
width?: number | null;
|
|
13
|
+
height?: number | null;
|
|
14
|
+
}
|
|
15
|
+
export interface CardVariant {
|
|
16
|
+
id: string;
|
|
17
|
+
availableForSale?: boolean;
|
|
18
|
+
image?: CardImage | null;
|
|
19
|
+
price?: Money;
|
|
20
|
+
compareAtPrice?: Money | null;
|
|
21
|
+
unitPrice?: Money | null;
|
|
22
|
+
unitPriceMeasurement?: UnitPriceMeasurement | null;
|
|
23
|
+
selectedOptions?: Array<{
|
|
24
|
+
name: string;
|
|
25
|
+
value: string;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
export interface CardSwatch {
|
|
29
|
+
color?: string | null;
|
|
30
|
+
image?: {
|
|
31
|
+
previewImage?: {
|
|
32
|
+
url?: string | null;
|
|
33
|
+
} | null;
|
|
34
|
+
} | null;
|
|
35
|
+
}
|
|
36
|
+
export interface CardOptionValue {
|
|
37
|
+
name: string;
|
|
38
|
+
swatch?: CardSwatch | null;
|
|
39
|
+
firstSelectableVariant?: CardVariant | null;
|
|
40
|
+
}
|
|
41
|
+
/** `metafield(...) { value }` node — null when the metafield is unset. */
|
|
42
|
+
export interface CardMetafield {
|
|
43
|
+
value?: string | null;
|
|
44
|
+
}
|
|
45
|
+
export interface CardProduct {
|
|
46
|
+
id: string;
|
|
47
|
+
handle: string;
|
|
48
|
+
title: string;
|
|
49
|
+
vendor?: string;
|
|
50
|
+
tags?: string[];
|
|
51
|
+
availableForSale?: boolean;
|
|
52
|
+
featuredImage?: CardImage | null;
|
|
53
|
+
images?: {
|
|
54
|
+
nodes: CardImage[];
|
|
55
|
+
};
|
|
56
|
+
priceRange: {
|
|
57
|
+
minVariantPrice: Money;
|
|
58
|
+
maxVariantPrice: Money;
|
|
59
|
+
};
|
|
60
|
+
compareAtPriceRange?: {
|
|
61
|
+
minVariantPrice: Money;
|
|
62
|
+
maxVariantPrice: Money;
|
|
63
|
+
};
|
|
64
|
+
options?: Array<{
|
|
65
|
+
name: string;
|
|
66
|
+
optionValues: CardOptionValue[];
|
|
67
|
+
}>;
|
|
68
|
+
selectedOrFirstAvailableVariant?: CardVariant | null;
|
|
69
|
+
/** Full variant list — mirror of Liquid's `product.variants`. */
|
|
70
|
+
variants?: {
|
|
71
|
+
nodes: CardVariant[];
|
|
72
|
+
};
|
|
73
|
+
/** reviews.rating / reviews.rating_count standardized metafields. */
|
|
74
|
+
rating?: CardMetafield | null;
|
|
75
|
+
ratingCount?: CardMetafield | null;
|
|
76
|
+
[key: string]: unknown;
|
|
77
|
+
}
|
|
78
|
+
export type CardOption = NonNullable<CardProduct['options']>[number];
|
|
79
|
+
/** Product URL the way every card/quick-add link builds it. */
|
|
80
|
+
export declare function productUrl(product: Pick<CardProduct, 'handle'>): string;
|
|
81
|
+
/** Numeric tail of a Storefront GID (mirror of Liquid's numeric ids). */
|
|
82
|
+
export declare function numericId(gid: string): string;
|
|
83
|
+
/**
|
|
84
|
+
* Mirror of Liquid's product.has_only_default_variant: a single
|
|
85
|
+
* variant whose only option value is Shopify's "Default Title".
|
|
86
|
+
*/
|
|
87
|
+
export declare function hasOnlyDefaultVariant(product: CardProduct): boolean;
|
|
88
|
+
/** The option value a variant carries for a given option name. */
|
|
89
|
+
export declare function optionValueOf(variant: CardVariant, optionName: string): string | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* Which option a product card's variant picker should display — mirror
|
|
92
|
+
* of the picker_option resolution in snippets/product-card.liquid.
|
|
93
|
+
*
|
|
94
|
+
* @param colorNames localized names that mark an option as a color
|
|
95
|
+
* (the theme's `products.color_option_match` locale value, split).
|
|
96
|
+
*/
|
|
97
|
+
export declare function resolvePickerOption(product: CardProduct, swatchStyleSetting: string, colorNames: string[]): CardOption | null;
|
|
98
|
+
/**
|
|
99
|
+
* Prefer the first variant of this value that has an image — mirror of
|
|
100
|
+
* the target_variant loop in the Liquid card.
|
|
101
|
+
*/
|
|
102
|
+
export declare function targetVariantFor(product: CardProduct, option: CardOption, valueName: string): CardVariant | null;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Product URL the way every card/quick-add link builds it. */
|
|
2
|
+
export function productUrl(product) {
|
|
3
|
+
return `/products/${product.handle}`;
|
|
4
|
+
}
|
|
5
|
+
/** Numeric tail of a Storefront GID (mirror of Liquid's numeric ids). */
|
|
6
|
+
export function numericId(gid) {
|
|
7
|
+
return gid.split('/').pop()?.split('?')[0] ?? gid;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Mirror of Liquid's product.has_only_default_variant: a single
|
|
11
|
+
* variant whose only option value is Shopify's "Default Title".
|
|
12
|
+
*/
|
|
13
|
+
export function hasOnlyDefaultVariant(product) {
|
|
14
|
+
const options = product.options ?? [];
|
|
15
|
+
const first = options[0];
|
|
16
|
+
if (!first)
|
|
17
|
+
return true;
|
|
18
|
+
return (options.length === 1 &&
|
|
19
|
+
first.optionValues.length === 1 &&
|
|
20
|
+
first.optionValues[0]?.name === 'Default Title');
|
|
21
|
+
}
|
|
22
|
+
/** The option value a variant carries for a given option name. */
|
|
23
|
+
export function optionValueOf(variant, optionName) {
|
|
24
|
+
return variant.selectedOptions?.find((o) => o.name === optionName)?.value;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Which option a product card's variant picker should display — mirror
|
|
28
|
+
* of the picker_option resolution in snippets/product-card.liquid.
|
|
29
|
+
*
|
|
30
|
+
* @param colorNames localized names that mark an option as a color
|
|
31
|
+
* (the theme's `products.color_option_match` locale value, split).
|
|
32
|
+
*/
|
|
33
|
+
export function resolvePickerOption(product, swatchStyleSetting, colorNames) {
|
|
34
|
+
const options = product.options ?? [];
|
|
35
|
+
const variants = product.variants?.nodes ?? [];
|
|
36
|
+
if (swatchStyleSetting === 'swatch') {
|
|
37
|
+
// First option carrying swatch data, or named like a color
|
|
38
|
+
for (const option of options) {
|
|
39
|
+
// A single value is nothing to pick — matches the PDP picker
|
|
40
|
+
if (option.optionValues.length < 2)
|
|
41
|
+
continue;
|
|
42
|
+
if (option.optionValues[0]?.swatch)
|
|
43
|
+
return option;
|
|
44
|
+
const optionNameDown = option.name.toLowerCase();
|
|
45
|
+
if (colorNames.some((name) => optionNameDown.includes(name))) {
|
|
46
|
+
return option;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (swatchStyleSetting === 'variant_image') {
|
|
52
|
+
// First option whose first two values map to distinct variant
|
|
53
|
+
// images (same rule as the PDP image picker, probed cheaply)
|
|
54
|
+
for (const option of options) {
|
|
55
|
+
const probeIds = [];
|
|
56
|
+
for (const value of option.optionValues.slice(0, 2)) {
|
|
57
|
+
for (const variant of variants) {
|
|
58
|
+
if (optionValueOf(variant, option.name) === value.name &&
|
|
59
|
+
variant.image) {
|
|
60
|
+
probeIds.push(variant.image.id ?? variant.image.url);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (new Set(probeIds).size > 1)
|
|
66
|
+
return option;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Prefer the first variant of this value that has an image — mirror of
|
|
74
|
+
* the target_variant loop in the Liquid card.
|
|
75
|
+
*/
|
|
76
|
+
export function targetVariantFor(product, option, valueName) {
|
|
77
|
+
const variants = product.variants?.nodes ?? [];
|
|
78
|
+
let target = null;
|
|
79
|
+
for (const variant of variants) {
|
|
80
|
+
if (optionValueOf(variant, option.name) === valueName) {
|
|
81
|
+
if (!target)
|
|
82
|
+
target = variant;
|
|
83
|
+
if (variant.image)
|
|
84
|
+
return variant;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (target)
|
|
88
|
+
return target;
|
|
89
|
+
// Fragment without the variants list: fall back to the option
|
|
90
|
+
// value's own first selectable variant.
|
|
91
|
+
return (option.optionValues.find((value) => value.name === valueName)
|
|
92
|
+
?.firstSelectableVariant ?? null);
|
|
93
|
+
}
|