@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,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port of snippets/product-card.liquid — a product card for grids:
|
|
3
|
+
* square media (second image on hover), title, price, and badges
|
|
4
|
+
* (sold-out/sale/best-seller, plus merchant chips from "badge:<Label>"
|
|
5
|
+
* product tags), variant swatches / image thumbs, and quick add.
|
|
6
|
+
*
|
|
7
|
+
* The Liquid theme's <product-card> island (src/entries/cards.ts)
|
|
8
|
+
* drives swatch-hover previews and the quick-add dialog; here the same
|
|
9
|
+
* behavior lives in React state. CSS: app/styles/components/snippets-product-card.css
|
|
10
|
+
*/
|
|
11
|
+
import {useState} from 'react';
|
|
12
|
+
import type {CardProduct} from '../../commerce/index.ts';
|
|
13
|
+
import {
|
|
14
|
+
emit,
|
|
15
|
+
hasOnlyDefaultVariant,
|
|
16
|
+
numericId,
|
|
17
|
+
productUrl,
|
|
18
|
+
resolvePickerOption,
|
|
19
|
+
targetVariantFor,
|
|
20
|
+
} from '../../commerce/index.ts';
|
|
21
|
+
import {useAdapter} from '../adapter';
|
|
22
|
+
import {Price} from './Price';
|
|
23
|
+
import {Rating} from './Rating';
|
|
24
|
+
import {ProductBadges, hasProductBadges} from './ProductBadges';
|
|
25
|
+
import {swatchStyle} from './SwatchStyle';
|
|
26
|
+
import {imageUrl, PlaceholderSvg} from '../engine/images';
|
|
27
|
+
import {themeSettings} from '../engine/settings';
|
|
28
|
+
import {t} from '../engine/translate';
|
|
29
|
+
|
|
30
|
+
export interface ProductCardProps {
|
|
31
|
+
product: CardProduct;
|
|
32
|
+
loading?: 'eager' | 'lazy';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const CARD_SIZES = '(min-width: 64rem) 25vw, (min-width: 48rem) 33vw, 50vw';
|
|
36
|
+
const CARD_WIDTHS = [360, 540, 720, 800];
|
|
37
|
+
|
|
38
|
+
function cardSrcSet(url: string): string {
|
|
39
|
+
return CARD_WIDTHS.map((w) => `${imageUrl(url, w)} ${w}w`).join(', ');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Localized option names that mark an option as a color, for the
|
|
43
|
+
* picker resolution shared via commerce-core. */
|
|
44
|
+
function colorOptionNames(): string[] {
|
|
45
|
+
return t('products.color_option_match')
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.split(',')
|
|
48
|
+
.map((name) => name.trim())
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function ProductCard({product, loading = 'lazy'}: ProductCardProps) {
|
|
53
|
+
const {Link, CartAddForm} = useAdapter();
|
|
54
|
+
const url = productUrl(product);
|
|
55
|
+
const badgePosition =
|
|
56
|
+
(themeSettings.card_badge_position as string) ?? 'corner';
|
|
57
|
+
const showBadges = hasProductBadges(product);
|
|
58
|
+
const available = product.availableForSale !== false;
|
|
59
|
+
const showQuickAdd =
|
|
60
|
+
available && (themeSettings.card_show_quick_add ?? true);
|
|
61
|
+
|
|
62
|
+
const featuredImage = product.featuredImage ?? null;
|
|
63
|
+
// Mirror of `product.images[1]` — the second product image.
|
|
64
|
+
const secondaryImage =
|
|
65
|
+
(themeSettings.card_show_secondary_image ?? true)
|
|
66
|
+
? (product.images?.nodes?.[1] ?? null)
|
|
67
|
+
: null;
|
|
68
|
+
|
|
69
|
+
// Swatch-hover preview, crossfading over the base image. The Liquid
|
|
70
|
+
// island creates the <img> on first hover; React renders it once a
|
|
71
|
+
// swatch has actually been hovered.
|
|
72
|
+
const [preview, setPreview] = useState<{src: string; visible: boolean} | null>(
|
|
73
|
+
null,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const onSwatchEnter = (mediaUrl: string | undefined) => () => {
|
|
77
|
+
// Touch devices simulate mouseover on tap; mutating the media then
|
|
78
|
+
// makes iOS treat the tap as hover and swallow the navigation
|
|
79
|
+
if (!mediaUrl) return;
|
|
80
|
+
if (
|
|
81
|
+
typeof window !== 'undefined' &&
|
|
82
|
+
window.matchMedia('(hover: none)').matches
|
|
83
|
+
) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
setPreview((previous) =>
|
|
87
|
+
previous?.src === mediaUrl
|
|
88
|
+
? {src: mediaUrl, visible: true}
|
|
89
|
+
: {src: mediaUrl, visible: false},
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const onSwatchLeave = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
94
|
+
if (
|
|
95
|
+
event.relatedTarget instanceof Node &&
|
|
96
|
+
event.currentTarget.contains(event.relatedTarget)
|
|
97
|
+
) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
setPreview((previous) =>
|
|
101
|
+
previous ? {...previous, visible: false} : previous,
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const onQuickAdd = (event: React.MouseEvent) => {
|
|
106
|
+
// Shell missing (customized layout): let the link navigate to the PDP
|
|
107
|
+
if (
|
|
108
|
+
typeof document === 'undefined' ||
|
|
109
|
+
!document.getElementById('QuickAddDialog')
|
|
110
|
+
) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
emit('quick-add:open', {product});
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Rendered twice: hover overlay on the media (desktop) and inline
|
|
118
|
+
// under the variant picker (mobile, Glossier-style)
|
|
119
|
+
const quickAdd = hasOnlyDefaultVariant(product) ? (
|
|
120
|
+
<CartAddForm
|
|
121
|
+
lines={[
|
|
122
|
+
{
|
|
123
|
+
merchandiseId: product.selectedOrFirstAvailableVariant?.id ?? '',
|
|
124
|
+
quantity: 1,
|
|
125
|
+
},
|
|
126
|
+
]}
|
|
127
|
+
>
|
|
128
|
+
{({submitting}) => (
|
|
129
|
+
<button
|
|
130
|
+
type="submit"
|
|
131
|
+
className="button button--secondary product-card__quick-button"
|
|
132
|
+
disabled={submitting}
|
|
133
|
+
>
|
|
134
|
+
{t('products.add_to_bag')}
|
|
135
|
+
</button>
|
|
136
|
+
)}
|
|
137
|
+
</CartAddForm>
|
|
138
|
+
) : (
|
|
139
|
+
<Link
|
|
140
|
+
to={url}
|
|
141
|
+
className="button button--secondary product-card__quick-button"
|
|
142
|
+
onClick={onQuickAdd}
|
|
143
|
+
>
|
|
144
|
+
{t('products.add_to_bag')}
|
|
145
|
+
</Link>
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Variant display per theme settings: color swatches, variant image
|
|
149
|
+
// thumbs, or nothing.
|
|
150
|
+
const swatchStyleSetting =
|
|
151
|
+
(themeSettings.card_swatch_style as string) ?? 'variant_image';
|
|
152
|
+
const pickerOption = resolvePickerOption(
|
|
153
|
+
product,
|
|
154
|
+
swatchStyleSetting,
|
|
155
|
+
colorOptionNames(),
|
|
156
|
+
);
|
|
157
|
+
// Image thumbs: 3 + the +N tile fit a narrow mobile column
|
|
158
|
+
const valueLimit = swatchStyleSetting === 'variant_image' ? 3 : 4;
|
|
159
|
+
const extraCount = pickerOption
|
|
160
|
+
? pickerOption.optionValues.length - valueLimit
|
|
161
|
+
: 0;
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div className="product-card">
|
|
165
|
+
<div className="product-card__frame">
|
|
166
|
+
<Link
|
|
167
|
+
to={url}
|
|
168
|
+
className="product-card__media"
|
|
169
|
+
aria-label={product.title}
|
|
170
|
+
>
|
|
171
|
+
{featuredImage ? (
|
|
172
|
+
<>
|
|
173
|
+
<img
|
|
174
|
+
className="product-card__image"
|
|
175
|
+
src={imageUrl(featuredImage.url, 800)}
|
|
176
|
+
srcSet={cardSrcSet(featuredImage.url)}
|
|
177
|
+
sizes={CARD_SIZES}
|
|
178
|
+
loading={loading}
|
|
179
|
+
alt={featuredImage.altText ?? ''}
|
|
180
|
+
width={featuredImage.width ?? undefined}
|
|
181
|
+
height={featuredImage.height ?? undefined}
|
|
182
|
+
/>
|
|
183
|
+
{secondaryImage ? (
|
|
184
|
+
<img
|
|
185
|
+
className="product-card__image product-card__image--hover"
|
|
186
|
+
src={imageUrl(secondaryImage.url, 800)}
|
|
187
|
+
srcSet={cardSrcSet(secondaryImage.url)}
|
|
188
|
+
sizes={CARD_SIZES}
|
|
189
|
+
loading="lazy"
|
|
190
|
+
alt=""
|
|
191
|
+
aria-hidden="true"
|
|
192
|
+
width={secondaryImage.width ?? undefined}
|
|
193
|
+
height={secondaryImage.height ?? undefined}
|
|
194
|
+
/>
|
|
195
|
+
) : null}
|
|
196
|
+
</>
|
|
197
|
+
) : (
|
|
198
|
+
<PlaceholderSvg className="product-card__placeholder" />
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
{preview ? (
|
|
202
|
+
<img
|
|
203
|
+
className={`product-card__image product-card__image--preview${
|
|
204
|
+
preview.visible ? ' is-visible' : ''
|
|
205
|
+
}`}
|
|
206
|
+
src={preview.src}
|
|
207
|
+
alt=""
|
|
208
|
+
aria-hidden="true"
|
|
209
|
+
onLoad={() =>
|
|
210
|
+
setPreview((previous) =>
|
|
211
|
+
previous ? {...previous, visible: true} : previous,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
/>
|
|
215
|
+
) : null}
|
|
216
|
+
|
|
217
|
+
{badgePosition === 'corner' && showBadges ? (
|
|
218
|
+
<div className="product-card__badges">
|
|
219
|
+
<ProductBadges product={product} />
|
|
220
|
+
</div>
|
|
221
|
+
) : null}
|
|
222
|
+
</Link>
|
|
223
|
+
|
|
224
|
+
{showQuickAdd ? (
|
|
225
|
+
<div className="product-card__quick product-card__quick--overlay">
|
|
226
|
+
{quickAdd}
|
|
227
|
+
</div>
|
|
228
|
+
) : null}
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div
|
|
232
|
+
className="product-card__info stack"
|
|
233
|
+
style={{'--stack-gap': 'var(--space-2xs)'} as React.CSSProperties}
|
|
234
|
+
>
|
|
235
|
+
{badgePosition === 'below' && showBadges ? (
|
|
236
|
+
<div className="product-card__badges">
|
|
237
|
+
<ProductBadges product={product} />
|
|
238
|
+
</div>
|
|
239
|
+
) : null}
|
|
240
|
+
<h3 className="product-card__title">
|
|
241
|
+
<Link to={url}>{product.title}</Link>
|
|
242
|
+
</h3>
|
|
243
|
+
<Price
|
|
244
|
+
price={product.priceRange?.minVariantPrice}
|
|
245
|
+
compareAt={product.compareAtPriceRange?.minVariantPrice}
|
|
246
|
+
unitPrice={product.selectedOrFirstAvailableVariant?.unitPrice}
|
|
247
|
+
unitPriceMeasurement={
|
|
248
|
+
product.selectedOrFirstAvailableVariant?.unitPriceMeasurement
|
|
249
|
+
}
|
|
250
|
+
/>
|
|
251
|
+
{themeSettings.card_show_rating ? <Rating product={product} /> : null}
|
|
252
|
+
|
|
253
|
+
{pickerOption ? (
|
|
254
|
+
<div className="product-card__swatches" onMouseOut={onSwatchLeave}>
|
|
255
|
+
{pickerOption.optionValues.slice(0, valueLimit).map((value) => {
|
|
256
|
+
const targetVariant = targetVariantFor(
|
|
257
|
+
product,
|
|
258
|
+
pickerOption,
|
|
259
|
+
value.name,
|
|
260
|
+
);
|
|
261
|
+
const valueUrl = targetVariant
|
|
262
|
+
? `${url}?variant=${numericId(targetVariant.id)}`
|
|
263
|
+
: url;
|
|
264
|
+
const cardMediaUrl = targetVariant?.image
|
|
265
|
+
? imageUrl(targetVariant.image.url, 800)
|
|
266
|
+
: undefined;
|
|
267
|
+
|
|
268
|
+
if (swatchStyleSetting === 'variant_image') {
|
|
269
|
+
if (!targetVariant?.image) return null;
|
|
270
|
+
return (
|
|
271
|
+
<Link
|
|
272
|
+
key={value.name}
|
|
273
|
+
className="product-card__variant-thumb"
|
|
274
|
+
to={valueUrl}
|
|
275
|
+
data-card-media={cardMediaUrl}
|
|
276
|
+
onMouseOver={onSwatchEnter(cardMediaUrl)}
|
|
277
|
+
aria-label={`${product.title} – ${value.name}`}
|
|
278
|
+
>
|
|
279
|
+
<img
|
|
280
|
+
src={imageUrl(targetVariant.image.url, 80)}
|
|
281
|
+
loading="lazy"
|
|
282
|
+
alt={targetVariant.image.altText ?? ''}
|
|
283
|
+
/>
|
|
284
|
+
</Link>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return (
|
|
288
|
+
<Link
|
|
289
|
+
key={value.name}
|
|
290
|
+
className="swatch swatch--sm"
|
|
291
|
+
to={valueUrl}
|
|
292
|
+
data-card-media={cardMediaUrl}
|
|
293
|
+
onMouseOver={
|
|
294
|
+
cardMediaUrl ? onSwatchEnter(cardMediaUrl) : undefined
|
|
295
|
+
}
|
|
296
|
+
style={swatchStyle(value.name, value.swatch)}
|
|
297
|
+
aria-label={`${product.title} – ${value.name}`}
|
|
298
|
+
></Link>
|
|
299
|
+
);
|
|
300
|
+
})}
|
|
301
|
+
{extraCount > 0 ? (
|
|
302
|
+
swatchStyleSetting === 'variant_image' ? (
|
|
303
|
+
<Link
|
|
304
|
+
className="product-card__swatch-count product-card__swatch-count--tile"
|
|
305
|
+
to={url}
|
|
306
|
+
aria-label={t('products.choose_options')}
|
|
307
|
+
>
|
|
308
|
+
+{extraCount}
|
|
309
|
+
</Link>
|
|
310
|
+
) : (
|
|
311
|
+
<span className="product-card__swatch-count" aria-hidden="true">
|
|
312
|
+
+{extraCount}
|
|
313
|
+
</span>
|
|
314
|
+
)
|
|
315
|
+
) : null}
|
|
316
|
+
</div>
|
|
317
|
+
) : null}
|
|
318
|
+
|
|
319
|
+
{showQuickAdd ? (
|
|
320
|
+
<div className="product-card__quick product-card__quick--inline">
|
|
321
|
+
{quickAdd}
|
|
322
|
+
</div>
|
|
323
|
+
) : null}
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Storefront API fragment every section loader uses for product cards.
|
|
331
|
+
* Spread into queries as `...ProductCard` and interpolate the constant
|
|
332
|
+
* (it carries its own nested ProductCardVariant fragment).
|
|
333
|
+
*
|
|
334
|
+
* Mirrors what snippets/product-card.liquid + sections/quick-add.liquid
|
|
335
|
+
* read from the Liquid product drop: images, price/compare/unit price,
|
|
336
|
+
* the full variant list (option probing + quick-add variant
|
|
337
|
+
* resolution), option swatches, and the standardized review metafields.
|
|
338
|
+
*/
|
|
339
|
+
export const PRODUCT_CARD_FRAGMENT = `#graphql
|
|
340
|
+
fragment ProductCardVariant on ProductVariant {
|
|
341
|
+
id
|
|
342
|
+
availableForSale
|
|
343
|
+
image {
|
|
344
|
+
id
|
|
345
|
+
url
|
|
346
|
+
altText
|
|
347
|
+
width
|
|
348
|
+
height
|
|
349
|
+
}
|
|
350
|
+
price { amount currencyCode }
|
|
351
|
+
compareAtPrice { amount currencyCode }
|
|
352
|
+
unitPrice { amount currencyCode }
|
|
353
|
+
unitPriceMeasurement { referenceValue referenceUnit }
|
|
354
|
+
selectedOptions { name value }
|
|
355
|
+
}
|
|
356
|
+
fragment ProductCard on Product {
|
|
357
|
+
id
|
|
358
|
+
handle
|
|
359
|
+
title
|
|
360
|
+
vendor
|
|
361
|
+
tags
|
|
362
|
+
availableForSale
|
|
363
|
+
featuredImage {
|
|
364
|
+
id
|
|
365
|
+
url
|
|
366
|
+
altText
|
|
367
|
+
width
|
|
368
|
+
height
|
|
369
|
+
}
|
|
370
|
+
images(first: 2) {
|
|
371
|
+
nodes {
|
|
372
|
+
id
|
|
373
|
+
url
|
|
374
|
+
altText
|
|
375
|
+
width
|
|
376
|
+
height
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
priceRange {
|
|
380
|
+
minVariantPrice { amount currencyCode }
|
|
381
|
+
maxVariantPrice { amount currencyCode }
|
|
382
|
+
}
|
|
383
|
+
compareAtPriceRange {
|
|
384
|
+
minVariantPrice { amount currencyCode }
|
|
385
|
+
maxVariantPrice { amount currencyCode }
|
|
386
|
+
}
|
|
387
|
+
options {
|
|
388
|
+
name
|
|
389
|
+
optionValues {
|
|
390
|
+
name
|
|
391
|
+
swatch {
|
|
392
|
+
color
|
|
393
|
+
image {
|
|
394
|
+
previewImage { url }
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
firstSelectableVariant {
|
|
398
|
+
...ProductCardVariant
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
selectedOrFirstAvailableVariant {
|
|
403
|
+
...ProductCardVariant
|
|
404
|
+
}
|
|
405
|
+
variants(first: 50) {
|
|
406
|
+
nodes {
|
|
407
|
+
...ProductCardVariant
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
rating: metafield(namespace: "reviews", key: "rating") {
|
|
411
|
+
value
|
|
412
|
+
}
|
|
413
|
+
ratingCount: metafield(namespace: "reviews", key: "rating_count") {
|
|
414
|
+
value
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
` as const;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port of snippets/rating.liquid — compact review score (star, average,
|
|
3
|
+
* review count) from the standardized review metafields reviews.rating
|
|
4
|
+
* and reviews.rating_count (aliased `rating` / `ratingCount` in
|
|
5
|
+
* PRODUCT_CARD_FRAGMENT). Renders nothing for products without reviews.
|
|
6
|
+
* CSS: app/styles/components/snippets-rating.css
|
|
7
|
+
*/
|
|
8
|
+
import type {CardProduct} from '../../commerce/index.ts';
|
|
9
|
+
import {Icon} from './Icon';
|
|
10
|
+
import {t} from '../engine/translate';
|
|
11
|
+
|
|
12
|
+
interface RatingValue {
|
|
13
|
+
rating: number;
|
|
14
|
+
scaleMax: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `reviews.rating` is a `rating` type metafield; the Storefront API
|
|
19
|
+
* returns its value as JSON: {"value":"4.5","scale_min":"1.0","scale_max":"5.0"}.
|
|
20
|
+
*/
|
|
21
|
+
function parseRating(raw: string | null | undefined): RatingValue | null {
|
|
22
|
+
if (!raw) return null;
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
25
|
+
const rating = Number(parsed.value ?? parsed.rating);
|
|
26
|
+
if (!Number.isFinite(rating)) return null;
|
|
27
|
+
const scaleMax = Number(parsed.scale_max);
|
|
28
|
+
return {rating, scaleMax: Number.isFinite(scaleMax) ? scaleMax : 5};
|
|
29
|
+
} catch {
|
|
30
|
+
// Plain number value (some apps write a number_decimal metafield)
|
|
31
|
+
const rating = Number(raw);
|
|
32
|
+
return Number.isFinite(rating) ? {rating, scaleMax: 5} : null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function Rating({product}: {product: CardProduct}) {
|
|
37
|
+
const rating = parseRating(product.rating?.value);
|
|
38
|
+
const ratingCount = Number(product.ratingCount?.value ?? 0);
|
|
39
|
+
if (!rating || !(ratingCount > 0)) return null;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
className="rating"
|
|
44
|
+
aria-label={t('products.rating_label', {
|
|
45
|
+
rating: rating.rating,
|
|
46
|
+
max: rating.scaleMax,
|
|
47
|
+
count: ratingCount,
|
|
48
|
+
})}
|
|
49
|
+
>
|
|
50
|
+
<span className="rating__star" aria-hidden="true">
|
|
51
|
+
<Icon name="icon-star" />
|
|
52
|
+
</span>
|
|
53
|
+
<span aria-hidden="true">{Math.round(rating.rating * 10) / 10}</span>
|
|
54
|
+
<span className="rating__count" aria-hidden="true">
|
|
55
|
+
({ratingCount})
|
|
56
|
+
</span>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port of snippets/swatch-style.liquid — resolves the fill for a color
|
|
3
|
+
* swatch. The Liquid snippet emits a `--swatch: …` inline style
|
|
4
|
+
* declaration (not a stylesheet), so the mirror is a helper returning
|
|
5
|
+
* a React style object — or undefined when the value can't be
|
|
6
|
+
* resolved, in which case the .swatch CSS paints a neutral chip.
|
|
7
|
+
*
|
|
8
|
+
* Resolution order:
|
|
9
|
+
* 1. The value's native swatch (admin-managed taxonomy colors)
|
|
10
|
+
* 2. The value's own name read as a CSS color keyword
|
|
11
|
+
* ("Light Blue" -> lightblue)
|
|
12
|
+
*
|
|
13
|
+
* The Liquid snippet's step 2 (the "swatch_color" metaobject matched
|
|
14
|
+
* by label) needs shop.metaobjects, which isn't part of the card
|
|
15
|
+
* data — intentionally skipped in the mirror.
|
|
16
|
+
*/
|
|
17
|
+
import type {CSSProperties} from 'react';
|
|
18
|
+
import type {CardSwatch} from '../../commerce/index.ts';
|
|
19
|
+
import {imageUrl} from '../engine/images';
|
|
20
|
+
|
|
21
|
+
// Only vetted CSS keywords may pass through — an arbitrary name would
|
|
22
|
+
// invalidate the whole background declaration
|
|
23
|
+
const CSS_COLORS = new Set(
|
|
24
|
+
'aliceblue,antiquewhite,aqua,aquamarine,azure,beige,bisque,black,blanchedalmond,blue,blueviolet,brown,burlywood,cadetblue,chartreuse,chocolate,coral,cornflowerblue,cornsilk,crimson,cyan,darkblue,darkcyan,darkgoldenrod,darkgray,darkgreen,darkgrey,darkkhaki,darkmagenta,darkolivegreen,darkorange,darkorchid,darkred,darksalmon,darkseagreen,darkslateblue,darkslategray,darkslategrey,darkturquoise,darkviolet,deeppink,deepskyblue,dimgray,dimgrey,dodgerblue,firebrick,floralwhite,forestgreen,fuchsia,gainsboro,ghostwhite,gold,goldenrod,gray,green,greenyellow,grey,honeydew,hotpink,indianred,indigo,ivory,khaki,lavender,lavenderblush,lawngreen,lemonchiffon,lightblue,lightcoral,lightcyan,lightgoldenrodyellow,lightgray,lightgreen,lightgrey,lightpink,lightsalmon,lightseagreen,lightskyblue,lightslategray,lightslategrey,lightsteelblue,lightyellow,lime,limegreen,linen,magenta,maroon,mediumaquamarine,mediumblue,mediumorchid,mediumpurple,mediumseagreen,mediumslateblue,mediumspringgreen,mediumturquoise,mediumvioletred,midnightblue,mintcream,mistyrose,moccasin,navajowhite,navy,oldlace,olive,olivedrab,orange,orangered,orchid,palegoldenrod,palegreen,paleturquoise,palevioletred,papayawhip,peachpuff,peru,pink,plum,powderblue,purple,rebeccapurple,red,rosybrown,royalblue,saddlebrown,salmon,sandybrown,seagreen,seashell,sienna,silver,skyblue,slateblue,slategray,slategrey,snow,springgreen,steelblue,tan,teal,thistle,tomato,turquoise,violet,wheat,white,whitesmoke,yellow,yellowgreen'.split(
|
|
25
|
+
',',
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `style={swatchStyle(value.name, value.swatch)}` ≙
|
|
31
|
+
* `style='{% render 'swatch-style', swatch: value.swatch, name: value.name %}'`
|
|
32
|
+
*/
|
|
33
|
+
export function swatchStyle(
|
|
34
|
+
name: string,
|
|
35
|
+
swatch?: CardSwatch | null,
|
|
36
|
+
): CSSProperties | undefined {
|
|
37
|
+
const imageSrc = swatch?.image?.previewImage?.url;
|
|
38
|
+
if (imageSrc) {
|
|
39
|
+
return {'--swatch': `url(${imageUrl(imageSrc, 100)})`} as CSSProperties;
|
|
40
|
+
}
|
|
41
|
+
if (swatch?.color) {
|
|
42
|
+
return {'--swatch': swatch.color} as CSSProperties;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const probe = name.toLowerCase().trim().replace(/ /g, '').replace(/-/g, '');
|
|
46
|
+
if (CSS_COLORS.has(probe)) {
|
|
47
|
+
return {'--swatch': probe} as CSSProperties;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|