@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,177 @@
|
|
|
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
|
+
|
|
9
|
+
export interface CardImage {
|
|
10
|
+
id?: string | null;
|
|
11
|
+
url: string;
|
|
12
|
+
altText?: string | null;
|
|
13
|
+
width?: number | null;
|
|
14
|
+
height?: number | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CardVariant {
|
|
18
|
+
id: string;
|
|
19
|
+
availableForSale?: boolean;
|
|
20
|
+
image?: CardImage | null;
|
|
21
|
+
price?: Money;
|
|
22
|
+
compareAtPrice?: Money | null;
|
|
23
|
+
unitPrice?: Money | null;
|
|
24
|
+
unitPriceMeasurement?: UnitPriceMeasurement | null;
|
|
25
|
+
selectedOptions?: Array<{name: string; value: string}>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CardSwatch {
|
|
29
|
+
color?: string | null;
|
|
30
|
+
image?: {previewImage?: {url?: string | null} | null} | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CardOptionValue {
|
|
34
|
+
name: string;
|
|
35
|
+
swatch?: CardSwatch | null;
|
|
36
|
+
firstSelectableVariant?: CardVariant | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `metafield(...) { value }` node — null when the metafield is unset. */
|
|
40
|
+
export interface CardMetafield {
|
|
41
|
+
value?: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CardProduct {
|
|
45
|
+
id: string;
|
|
46
|
+
handle: string;
|
|
47
|
+
title: string;
|
|
48
|
+
vendor?: string;
|
|
49
|
+
tags?: string[];
|
|
50
|
+
availableForSale?: boolean;
|
|
51
|
+
featuredImage?: CardImage | null;
|
|
52
|
+
images?: {nodes: CardImage[]};
|
|
53
|
+
priceRange: {minVariantPrice: Money; maxVariantPrice: Money};
|
|
54
|
+
compareAtPriceRange?: {minVariantPrice: Money; maxVariantPrice: Money};
|
|
55
|
+
options?: Array<{name: string; optionValues: CardOptionValue[]}>;
|
|
56
|
+
selectedOrFirstAvailableVariant?: CardVariant | null;
|
|
57
|
+
/** Full variant list — mirror of Liquid's `product.variants`. */
|
|
58
|
+
variants?: {nodes: CardVariant[]};
|
|
59
|
+
/** reviews.rating / reviews.rating_count standardized metafields. */
|
|
60
|
+
rating?: CardMetafield | null;
|
|
61
|
+
ratingCount?: CardMetafield | null;
|
|
62
|
+
[key: string]: unknown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type CardOption = NonNullable<CardProduct['options']>[number];
|
|
66
|
+
|
|
67
|
+
/** Product URL the way every card/quick-add link builds it. */
|
|
68
|
+
export function productUrl(product: Pick<CardProduct, 'handle'>): string {
|
|
69
|
+
return `/products/${product.handle}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Numeric tail of a Storefront GID (mirror of Liquid's numeric ids). */
|
|
73
|
+
export function numericId(gid: string): string {
|
|
74
|
+
return gid.split('/').pop()?.split('?')[0] ?? gid;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Mirror of Liquid's product.has_only_default_variant: a single
|
|
79
|
+
* variant whose only option value is Shopify's "Default Title".
|
|
80
|
+
*/
|
|
81
|
+
export function hasOnlyDefaultVariant(product: CardProduct): boolean {
|
|
82
|
+
const options = product.options ?? [];
|
|
83
|
+
const first = options[0];
|
|
84
|
+
if (!first) return true;
|
|
85
|
+
return (
|
|
86
|
+
options.length === 1 &&
|
|
87
|
+
first.optionValues.length === 1 &&
|
|
88
|
+
first.optionValues[0]?.name === 'Default Title'
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The option value a variant carries for a given option name. */
|
|
93
|
+
export function optionValueOf(
|
|
94
|
+
variant: CardVariant,
|
|
95
|
+
optionName: string,
|
|
96
|
+
): string | undefined {
|
|
97
|
+
return variant.selectedOptions?.find((o) => o.name === optionName)?.value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Which option a product card's variant picker should display — mirror
|
|
102
|
+
* of the picker_option resolution in snippets/product-card.liquid.
|
|
103
|
+
*
|
|
104
|
+
* @param colorNames localized names that mark an option as a color
|
|
105
|
+
* (the theme's `products.color_option_match` locale value, split).
|
|
106
|
+
*/
|
|
107
|
+
export function resolvePickerOption(
|
|
108
|
+
product: CardProduct,
|
|
109
|
+
swatchStyleSetting: string,
|
|
110
|
+
colorNames: string[],
|
|
111
|
+
): CardOption | null {
|
|
112
|
+
const options = product.options ?? [];
|
|
113
|
+
const variants = product.variants?.nodes ?? [];
|
|
114
|
+
|
|
115
|
+
if (swatchStyleSetting === 'swatch') {
|
|
116
|
+
// First option carrying swatch data, or named like a color
|
|
117
|
+
for (const option of options) {
|
|
118
|
+
// A single value is nothing to pick — matches the PDP picker
|
|
119
|
+
if (option.optionValues.length < 2) continue;
|
|
120
|
+
if (option.optionValues[0]?.swatch) return option;
|
|
121
|
+
const optionNameDown = option.name.toLowerCase();
|
|
122
|
+
if (colorNames.some((name) => optionNameDown.includes(name))) {
|
|
123
|
+
return option;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (swatchStyleSetting === 'variant_image') {
|
|
130
|
+
// First option whose first two values map to distinct variant
|
|
131
|
+
// images (same rule as the PDP image picker, probed cheaply)
|
|
132
|
+
for (const option of options) {
|
|
133
|
+
const probeIds: string[] = [];
|
|
134
|
+
for (const value of option.optionValues.slice(0, 2)) {
|
|
135
|
+
for (const variant of variants) {
|
|
136
|
+
if (
|
|
137
|
+
optionValueOf(variant, option.name) === value.name &&
|
|
138
|
+
variant.image
|
|
139
|
+
) {
|
|
140
|
+
probeIds.push(variant.image.id ?? variant.image.url);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (new Set(probeIds).size > 1) return option;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Prefer the first variant of this value that has an image — mirror of
|
|
155
|
+
* the target_variant loop in the Liquid card.
|
|
156
|
+
*/
|
|
157
|
+
export function targetVariantFor(
|
|
158
|
+
product: CardProduct,
|
|
159
|
+
option: CardOption,
|
|
160
|
+
valueName: string,
|
|
161
|
+
): CardVariant | null {
|
|
162
|
+
const variants = product.variants?.nodes ?? [];
|
|
163
|
+
let target: CardVariant | null = null;
|
|
164
|
+
for (const variant of variants) {
|
|
165
|
+
if (optionValueOf(variant, option.name) === valueName) {
|
|
166
|
+
if (!target) target = variant;
|
|
167
|
+
if (variant.image) return variant;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (target) return target;
|
|
171
|
+
// Fragment without the variants list: fall back to the option
|
|
172
|
+
// value's own first selectable variant.
|
|
173
|
+
return (
|
|
174
|
+
option.optionValues.find((value) => value.name === valueName)
|
|
175
|
+
?.firstSelectableVariant ?? null
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame side of the canvas-editor bridge: runs inside the theme
|
|
3
|
+
* preview when it is iframed by the Zalify editor. Owns everything
|
|
4
|
+
* that needs same-document DOM access — hit-testing clicks/hovers to
|
|
5
|
+
* `data-z-path` nodes, selection highlight, rect reporting, height
|
|
6
|
+
* sync — and hands app-specific concerns (template hot-apply, device
|
|
7
|
+
* emulation) to callbacks.
|
|
8
|
+
*
|
|
9
|
+
* Editor mode neutralizes the page's own interactivity: clicks,
|
|
10
|
+
* double/middle clicks, form submits, and context menus are captured
|
|
11
|
+
* at the document root and suppressed, so selecting a node never
|
|
12
|
+
* navigates a link, opens a drawer, or submits a form — a click is
|
|
13
|
+
* only ever a selection. (Hover stays live for highlight tracking.)
|
|
14
|
+
*
|
|
15
|
+
* Security: the first `bridge:init` pins the editor origin; every
|
|
16
|
+
* later message must match it, and nothing but `bridge:ready` is ever
|
|
17
|
+
* posted before the pin.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
BRIDGE_NAMESPACE,
|
|
21
|
+
CONTRACT_VERSION,
|
|
22
|
+
DATA_PATH_ATTR,
|
|
23
|
+
DATA_SELECTED_ATTR,
|
|
24
|
+
EDITOR_MODE_PARAM,
|
|
25
|
+
isBridgeMessage,
|
|
26
|
+
isCompatibleVersion,
|
|
27
|
+
type Device,
|
|
28
|
+
type DOMRectLike,
|
|
29
|
+
type FrameMessage,
|
|
30
|
+
type HostMessage,
|
|
31
|
+
type SectionGroupData,
|
|
32
|
+
type SettingsBag,
|
|
33
|
+
type TemplateData,
|
|
34
|
+
type ThemeEditorManifest,
|
|
35
|
+
} from '../schemas/index.ts';
|
|
36
|
+
|
|
37
|
+
export const DATA_HOVERED_ATTR = 'data-z-hovered';
|
|
38
|
+
|
|
39
|
+
export interface FrameBridgeOptions {
|
|
40
|
+
/** Template currently rendered ("index", "product", …). */
|
|
41
|
+
templateName: string;
|
|
42
|
+
/** Content hash of the theme data the frame booted with. */
|
|
43
|
+
hash: string;
|
|
44
|
+
/** Serve the editor manifest (static import or dynamic build). */
|
|
45
|
+
getManifest: () =>
|
|
46
|
+
| ThemeEditorManifest
|
|
47
|
+
| Promise<ThemeEditorManifest>;
|
|
48
|
+
/**
|
|
49
|
+
* Hot-apply an updated template in memory (optimistic edit). Return
|
|
50
|
+
* false (or throw) if the app cannot apply without a reload.
|
|
51
|
+
*/
|
|
52
|
+
applyTemplate?: (payload: {
|
|
53
|
+
templateName: string;
|
|
54
|
+
template: TemplateData;
|
|
55
|
+
groups?: Record<string, SectionGroupData>;
|
|
56
|
+
settingsData?: SettingsBag;
|
|
57
|
+
}) => boolean | Promise<boolean>;
|
|
58
|
+
onDeviceChange?: (device: Device) => void;
|
|
59
|
+
/** Overrides for tests / non-browser hosts. */
|
|
60
|
+
window?: Window & typeof globalThis;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when this document should mount the editor bridge. */
|
|
64
|
+
export function isEditorMode(win: Window = window): boolean {
|
|
65
|
+
if (win.self === win.top) return false;
|
|
66
|
+
try {
|
|
67
|
+
return (
|
|
68
|
+
new URL(win.location.href).searchParams.get(EDITOR_MODE_PARAM) === '1'
|
|
69
|
+
);
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function rectOf(element: Element): DOMRectLike {
|
|
76
|
+
const rect = element.getBoundingClientRect();
|
|
77
|
+
return {x: rect.x, y: rect.y, width: rect.width, height: rect.height};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function pathNodeOf(target: EventTarget | null): HTMLElement | null {
|
|
81
|
+
if (!(target instanceof Element)) return null;
|
|
82
|
+
return target.closest<HTMLElement>(`[${DATA_PATH_ATTR}]`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface FrameBridgeController {
|
|
86
|
+
unmount: () => void;
|
|
87
|
+
/** Report that a persisted write round-tripped (HMR applied `hash`). */
|
|
88
|
+
notifySynced: (hash: string) => void;
|
|
89
|
+
/** Report an in-preview navigation to another template. */
|
|
90
|
+
notifyNavigation: (templateName: string, url: string) => void;
|
|
91
|
+
/** Re-send every block's rect (call after app-driven re-renders). */
|
|
92
|
+
reportRects: () => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Mount the frame bridge. Returns a controller with an `unmount`
|
|
97
|
+
* function. Call only when `isEditorMode()` is true.
|
|
98
|
+
*/
|
|
99
|
+
export function mountFrameBridge(
|
|
100
|
+
options: FrameBridgeOptions,
|
|
101
|
+
): FrameBridgeController {
|
|
102
|
+
const win = options.window ?? window;
|
|
103
|
+
const doc = win.document;
|
|
104
|
+
let editorOrigin: string | null = null;
|
|
105
|
+
let lastHeight = 0;
|
|
106
|
+
|
|
107
|
+
const post = (message: Omit<FrameMessage, 'z' | 'v'>): void => {
|
|
108
|
+
// bridge:ready is the only pre-pin message; everything else waits.
|
|
109
|
+
const target = editorOrigin ?? '*';
|
|
110
|
+
if (!editorOrigin && message.type !== 'bridge:ready') return;
|
|
111
|
+
win.parent.postMessage(
|
|
112
|
+
{z: BRIDGE_NAMESPACE, v: CONTRACT_VERSION, ...message},
|
|
113
|
+
target,
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const setUnique = (attr: string, path: string | null): void => {
|
|
118
|
+
for (const node of doc.querySelectorAll(`[${attr}]`)) {
|
|
119
|
+
node.removeAttribute(attr);
|
|
120
|
+
}
|
|
121
|
+
if (path === null) return;
|
|
122
|
+
const selector = `[${DATA_PATH_ATTR}="${CSS.escape(path)}"]`;
|
|
123
|
+
doc.querySelector(selector)?.setAttribute(attr, '1');
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const reportRects = (): void => {
|
|
127
|
+
const rects = [...doc.querySelectorAll(`[${DATA_PATH_ATTR}]`)].map(
|
|
128
|
+
(node) => ({
|
|
129
|
+
path: node.getAttribute(DATA_PATH_ATTR)!,
|
|
130
|
+
rect: rectOf(node),
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
post({type: 'block:rects', payload: {rects}});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const handleMessage = async (event: MessageEvent): Promise<void> => {
|
|
137
|
+
const data: unknown = event.data;
|
|
138
|
+
if (!isBridgeMessage(data)) return;
|
|
139
|
+
if (editorOrigin && event.origin !== editorOrigin) return;
|
|
140
|
+
if (!isCompatibleVersion(CONTRACT_VERSION, data.v)) {
|
|
141
|
+
post({
|
|
142
|
+
type: 'bridge:error',
|
|
143
|
+
payload: {
|
|
144
|
+
code: 'version-mismatch',
|
|
145
|
+
message: `frame speaks ${CONTRACT_VERSION}, host sent ${data.v}`,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const message = data as HostMessage;
|
|
151
|
+
switch (message.type) {
|
|
152
|
+
case 'bridge:init': {
|
|
153
|
+
if (!editorOrigin) {
|
|
154
|
+
if (event.origin !== message.payload.editorOrigin) return;
|
|
155
|
+
editorOrigin = event.origin;
|
|
156
|
+
}
|
|
157
|
+
options.onDeviceChange?.(message.payload.device);
|
|
158
|
+
setUnique(DATA_SELECTED_ATTR, message.payload.selectedPath);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case 'block:select':
|
|
162
|
+
setUnique(DATA_SELECTED_ATTR, message.payload.path);
|
|
163
|
+
// Rects let the host scroll its full-height canvas to the
|
|
164
|
+
// selection (the frame itself has no scrollbar in that mode).
|
|
165
|
+
reportRects();
|
|
166
|
+
break;
|
|
167
|
+
case 'block:hover':
|
|
168
|
+
setUnique(DATA_HOVERED_ATTR, message.payload.path);
|
|
169
|
+
break;
|
|
170
|
+
case 'block:scroll-to': {
|
|
171
|
+
const selector = `[${DATA_PATH_ATTR}="${CSS.escape(message.payload.path)}"]`;
|
|
172
|
+
doc
|
|
173
|
+
.querySelector(selector)
|
|
174
|
+
?.scrollIntoView({behavior: 'smooth', block: 'center'});
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case 'template:apply': {
|
|
178
|
+
try {
|
|
179
|
+
const applied =
|
|
180
|
+
(await options.applyTemplate?.(message.payload)) ?? false;
|
|
181
|
+
if (!applied) {
|
|
182
|
+
post({
|
|
183
|
+
type: 'bridge:error',
|
|
184
|
+
payload: {
|
|
185
|
+
code: 'apply-unsupported',
|
|
186
|
+
message: 'frame cannot hot-apply templates; wait for the file write',
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
} else {
|
|
190
|
+
reportRects();
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
post({
|
|
194
|
+
type: 'bridge:error',
|
|
195
|
+
payload: {
|
|
196
|
+
code: 'apply-failed',
|
|
197
|
+
message: error instanceof Error ? error.message : String(error),
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
case 'device:set':
|
|
204
|
+
options.onDeviceChange?.(message.payload.device);
|
|
205
|
+
break;
|
|
206
|
+
case 'manifest:request': {
|
|
207
|
+
const manifest = await options.getManifest();
|
|
208
|
+
post({type: 'manifest:response', payload: {manifest}});
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const handleClick = (event: MouseEvent): void => {
|
|
215
|
+
const node = pathNodeOf(event.target);
|
|
216
|
+
// Editor mode owns clicks: no navigation, no dialogs.
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
event.stopPropagation();
|
|
219
|
+
if (!node) return;
|
|
220
|
+
const path = node.getAttribute(DATA_PATH_ATTR)!;
|
|
221
|
+
setUnique(DATA_SELECTED_ATTR, path);
|
|
222
|
+
post({type: 'block:clicked', payload: {path, rect: rectOf(node)}});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/** Suppress the page's own interactivity (capture phase, doc root). */
|
|
226
|
+
const suppress = (event: Event): void => {
|
|
227
|
+
event.preventDefault();
|
|
228
|
+
event.stopPropagation();
|
|
229
|
+
};
|
|
230
|
+
const SUPPRESSED_EVENTS = [
|
|
231
|
+
'dblclick',
|
|
232
|
+
'auxclick',
|
|
233
|
+
'submit',
|
|
234
|
+
'contextmenu',
|
|
235
|
+
] as const;
|
|
236
|
+
|
|
237
|
+
const handleMouseMove = (() => {
|
|
238
|
+
let lastPath: string | null = null;
|
|
239
|
+
return (event: MouseEvent): void => {
|
|
240
|
+
const node = pathNodeOf(event.target);
|
|
241
|
+
const path = node?.getAttribute(DATA_PATH_ATTR) ?? null;
|
|
242
|
+
if (path === lastPath) return;
|
|
243
|
+
lastPath = path;
|
|
244
|
+
setUnique(DATA_HOVERED_ATTR, path);
|
|
245
|
+
post({
|
|
246
|
+
type: 'block:hovered',
|
|
247
|
+
payload: {path, rect: node ? rectOf(node) : undefined},
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
})();
|
|
251
|
+
|
|
252
|
+
const measure = (): void => {
|
|
253
|
+
// documentElement.scrollHeight never drops below the viewport, so a
|
|
254
|
+
// full-height host iframe would ratchet upward forever; the body's
|
|
255
|
+
// border box tracks actual content in both directions.
|
|
256
|
+
const height = Math.ceil(
|
|
257
|
+
doc.body?.getBoundingClientRect().height ??
|
|
258
|
+
doc.documentElement.scrollHeight,
|
|
259
|
+
);
|
|
260
|
+
if (height === lastHeight || height <= 0) return;
|
|
261
|
+
lastHeight = height;
|
|
262
|
+
post({type: 'height:changed', payload: {height}});
|
|
263
|
+
};
|
|
264
|
+
const resizeObserver = new win.ResizeObserver(measure);
|
|
265
|
+
resizeObserver.observe(doc.documentElement);
|
|
266
|
+
if (doc.body) resizeObserver.observe(doc.body);
|
|
267
|
+
|
|
268
|
+
win.addEventListener('message', handleMessage);
|
|
269
|
+
doc.addEventListener('click', handleClick, true);
|
|
270
|
+
doc.addEventListener('mousemove', handleMouseMove, true);
|
|
271
|
+
for (const type of SUPPRESSED_EVENTS) {
|
|
272
|
+
doc.addEventListener(type, suppress, true);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
post({
|
|
276
|
+
type: 'bridge:ready',
|
|
277
|
+
payload: {
|
|
278
|
+
contractVersion: CONTRACT_VERSION,
|
|
279
|
+
templateName: options.templateName,
|
|
280
|
+
hash: options.hash,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
measure();
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
unmount: () => {
|
|
287
|
+
win.removeEventListener('message', handleMessage);
|
|
288
|
+
doc.removeEventListener('click', handleClick, true);
|
|
289
|
+
doc.removeEventListener('mousemove', handleMouseMove, true);
|
|
290
|
+
for (const type of SUPPRESSED_EVENTS) {
|
|
291
|
+
doc.removeEventListener(type, suppress, true);
|
|
292
|
+
}
|
|
293
|
+
resizeObserver.disconnect();
|
|
294
|
+
setUnique(DATA_SELECTED_ATTR, null);
|
|
295
|
+
setUnique(DATA_HOVERED_ATTR, null);
|
|
296
|
+
},
|
|
297
|
+
notifySynced: (hash) => {
|
|
298
|
+
post({type: 'template:synced', payload: {hash}});
|
|
299
|
+
},
|
|
300
|
+
notifyNavigation: (templateName, url) => {
|
|
301
|
+
post({type: 'navigation', payload: {templateName, url}});
|
|
302
|
+
},
|
|
303
|
+
reportRects,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host side of the canvas-editor bridge: drives a theme preview iframe
|
|
3
|
+
* from the Zalify editor. Owns the handshake (pin the frame origin,
|
|
4
|
+
* answer `bridge:ready` with `bridge:init`), re-handshakes transparently
|
|
5
|
+
* when the iframe reloads (sandbox HMR hard-refresh, crash recovery),
|
|
6
|
+
* and exposes typed send/subscribe surfaces.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
BRIDGE_NAMESPACE,
|
|
10
|
+
CONTRACT_VERSION,
|
|
11
|
+
EDITOR_MODE_PARAM,
|
|
12
|
+
isBridgeMessage,
|
|
13
|
+
isCompatibleVersion,
|
|
14
|
+
type Device,
|
|
15
|
+
type FrameMessage,
|
|
16
|
+
type HostMessage,
|
|
17
|
+
} from '../schemas/index.ts';
|
|
18
|
+
|
|
19
|
+
export interface HostBridgeOptions {
|
|
20
|
+
iframe: HTMLIFrameElement;
|
|
21
|
+
/** Origin the preview is served from (the sandbox preview URL). */
|
|
22
|
+
frameOrigin: string;
|
|
23
|
+
device?: Device;
|
|
24
|
+
/** Currently selected path, replayed on every (re)handshake. */
|
|
25
|
+
getSelectedPath?: () => string | null;
|
|
26
|
+
onMessage?: (message: FrameMessage) => void;
|
|
27
|
+
/** Called when the frame speaks an incompatible contract version. */
|
|
28
|
+
onIncompatible?: (frameVersion: string) => void;
|
|
29
|
+
/** Overrides for tests / non-browser hosts. */
|
|
30
|
+
window?: Window;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface HostBridge {
|
|
34
|
+
/** Send a message; silently dropped until the frame is ready. */
|
|
35
|
+
send: (message: Omit<HostMessage, 'z' | 'v'>) => void;
|
|
36
|
+
/** True after the current frame document completed the handshake. */
|
|
37
|
+
isReady: () => boolean;
|
|
38
|
+
destroy: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Append the editor-mode marker to a preview URL. */
|
|
42
|
+
export function editorPreviewUrl(previewUrl: string): string {
|
|
43
|
+
const url = new URL(previewUrl);
|
|
44
|
+
url.searchParams.set(EDITOR_MODE_PARAM, '1');
|
|
45
|
+
return url.toString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createHostBridge(options: HostBridgeOptions): HostBridge {
|
|
49
|
+
const win = options.window ?? window;
|
|
50
|
+
let ready = false;
|
|
51
|
+
let destroyed = false;
|
|
52
|
+
|
|
53
|
+
const post = (message: Omit<HostMessage, 'z' | 'v'>): void => {
|
|
54
|
+
if (destroyed) return;
|
|
55
|
+
options.iframe.contentWindow?.postMessage(
|
|
56
|
+
{z: BRIDGE_NAMESPACE, v: CONTRACT_VERSION, ...message},
|
|
57
|
+
options.frameOrigin,
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleMessage = (event: MessageEvent): void => {
|
|
62
|
+
if (destroyed) return;
|
|
63
|
+
if (event.origin !== options.frameOrigin) return;
|
|
64
|
+
if (event.source !== options.iframe.contentWindow) return;
|
|
65
|
+
const data: unknown = event.data;
|
|
66
|
+
if (!isBridgeMessage(data)) return;
|
|
67
|
+
if (!isCompatibleVersion(CONTRACT_VERSION, data.v)) {
|
|
68
|
+
ready = false;
|
|
69
|
+
options.onIncompatible?.(data.v);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const message = data as FrameMessage;
|
|
73
|
+
if (message.type === 'bridge:ready') {
|
|
74
|
+
// First load and every iframe reload land here: (re)init.
|
|
75
|
+
ready = true;
|
|
76
|
+
post({
|
|
77
|
+
type: 'bridge:init',
|
|
78
|
+
payload: {
|
|
79
|
+
editorOrigin: win.location.origin,
|
|
80
|
+
device: options.device ?? 'desktop',
|
|
81
|
+
selectedPath: options.getSelectedPath?.() ?? null,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
options.onMessage?.(message);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
win.addEventListener('message', handleMessage);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
send: (message) => {
|
|
92
|
+
if (ready) post(message);
|
|
93
|
+
},
|
|
94
|
+
isReady: () => ready,
|
|
95
|
+
destroy: () => {
|
|
96
|
+
destroyed = true;
|
|
97
|
+
ready = false;
|
|
98
|
+
win.removeEventListener('message', handleMessage);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zalify/storefront-kit/editor — both halves of the canvas-editor
|
|
3
|
+
* bridge. Protocol types come from @zalify/storefront-kit/schemas.
|
|
4
|
+
*
|
|
5
|
+
* frame.ts — mounts inside the theme preview (sandbox side)
|
|
6
|
+
* host.ts — drives the preview iframe (editor side)
|
|
7
|
+
*/
|
|
8
|
+
export * from './frame.ts';
|
|
9
|
+
export * from './host.ts';
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework seam. ui-react components never import a router or
|
|
3
|
+
* commerce SDK — the host app injects these primitives once:
|
|
4
|
+
*
|
|
5
|
+
* <AdapterProvider adapter={hydrogenAdapter}>…</AdapterProvider>
|
|
6
|
+
*
|
|
7
|
+
* Keep this surface deliberately small: only what shared components
|
|
8
|
+
* actually need. Deeply framework-coupled features (cart page, variant
|
|
9
|
+
* system, predictive search) live in each app, not behind this seam.
|
|
10
|
+
*/
|
|
11
|
+
import {createContext, useContext} from 'react';
|
|
12
|
+
import type {ComponentType, ReactNode} from 'react';
|
|
13
|
+
|
|
14
|
+
/** Loose Link props — hosts map them onto their router's Link. */
|
|
15
|
+
export interface AdapterLinkProps {
|
|
16
|
+
to: string;
|
|
17
|
+
className?: string;
|
|
18
|
+
children?: ReactNode;
|
|
19
|
+
/** Hint to prefetch on intent where the router supports it. */
|
|
20
|
+
prefetch?: boolean;
|
|
21
|
+
replace?: boolean;
|
|
22
|
+
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
|
23
|
+
'aria-label'?: string;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CartLineInput {
|
|
28
|
+
merchandiseId: string;
|
|
29
|
+
quantity: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CartAddFormProps {
|
|
33
|
+
lines: CartLineInput[];
|
|
34
|
+
/** Render prop receives the submission state for button disabling. */
|
|
35
|
+
children: (state: {submitting: boolean}) => ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ThemeAdapter {
|
|
39
|
+
/** Client-side navigation link. */
|
|
40
|
+
Link: ComponentType<AdapterLinkProps>;
|
|
41
|
+
/** Hook returning an imperative navigate(url, opts) function. */
|
|
42
|
+
useNavigate: () => (
|
|
43
|
+
url: string,
|
|
44
|
+
opts?: {replace?: boolean; preventScrollReset?: boolean},
|
|
45
|
+
) => void;
|
|
46
|
+
/** Hook returning the current URL's search params (read-only). */
|
|
47
|
+
useSearchParams: () => URLSearchParams;
|
|
48
|
+
/** Hook returning the current pathname. */
|
|
49
|
+
usePathname: () => string;
|
|
50
|
+
/**
|
|
51
|
+
* A form that adds lines to the cart on submit. Hosts wire it to
|
|
52
|
+
* their cart mutation (Hydrogen CartForm, Next server action…) and
|
|
53
|
+
* are responsible for emitting commerce-core's 'cart:updated' /
|
|
54
|
+
* 'cart:error' events when the mutation settles.
|
|
55
|
+
*/
|
|
56
|
+
CartAddForm: ComponentType<CartAddFormProps>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const AdapterContext = createContext<ThemeAdapter | null>(null);
|
|
60
|
+
|
|
61
|
+
export function AdapterProvider({
|
|
62
|
+
adapter,
|
|
63
|
+
children,
|
|
64
|
+
}: {
|
|
65
|
+
adapter: ThemeAdapter;
|
|
66
|
+
children: ReactNode;
|
|
67
|
+
}) {
|
|
68
|
+
return (
|
|
69
|
+
<AdapterContext.Provider value={adapter}>
|
|
70
|
+
{children}
|
|
71
|
+
</AdapterContext.Provider>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function useAdapter(): ThemeAdapter {
|
|
76
|
+
const adapter = useContext(AdapterContext);
|
|
77
|
+
if (!adapter) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'[storefront-kit] No ThemeAdapter provided. Wrap the app in <AdapterProvider adapter={…}>.',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return adapter;
|
|
83
|
+
}
|