@zalify/storefront-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/LICENSE.md +49 -0
  2. package/README.md +63 -0
  3. package/dist/commerce/countdown.d.ts +18 -0
  4. package/dist/commerce/countdown.js +28 -0
  5. package/dist/commerce/events.d.ts +29 -0
  6. package/dist/commerce/events.js +14 -0
  7. package/dist/commerce/facets.d.ts +27 -0
  8. package/dist/commerce/facets.js +71 -0
  9. package/dist/commerce/index.d.ts +11 -0
  10. package/dist/commerce/index.js +11 -0
  11. package/dist/commerce/money.d.ts +51 -0
  12. package/dist/commerce/money.js +83 -0
  13. package/dist/commerce/product.d.ts +102 -0
  14. package/dist/commerce/product.js +93 -0
  15. package/dist/editor/frame.d.ts +57 -0
  16. package/dist/editor/frame.js +241 -0
  17. package/dist/editor/host.d.ts +31 -0
  18. package/dist/editor/host.js +67 -0
  19. package/dist/editor/index.d.ts +9 -0
  20. package/dist/editor/index.js +9 -0
  21. package/dist/react/adapter.d.ts +49 -0
  22. package/dist/react/adapter.js +23 -0
  23. package/dist/react/blocks/_box.d.ts +10 -0
  24. package/dist/react/blocks/_box.js +17 -0
  25. package/dist/react/blocks/_slide.d.ts +24 -0
  26. package/dist/react/blocks/_slide.js +21 -0
  27. package/dist/react/blocks/_video-card.d.ts +11 -0
  28. package/dist/react/blocks/_video-card.js +39 -0
  29. package/dist/react/blocks/accordion.d.ts +12 -0
  30. package/dist/react/blocks/accordion.js +5 -0
  31. package/dist/react/blocks/button.d.ts +10 -0
  32. package/dist/react/blocks/button.js +15 -0
  33. package/dist/react/blocks/countdown.d.ts +9 -0
  34. package/dist/react/blocks/countdown.js +64 -0
  35. package/dist/react/blocks/custom-liquid.d.ts +11 -0
  36. package/dist/react/blocks/custom-liquid.js +4 -0
  37. package/dist/react/blocks/group.d.ts +9 -0
  38. package/dist/react/blocks/group.js +8 -0
  39. package/dist/react/blocks/image.d.ts +8 -0
  40. package/dist/react/blocks/image.js +7 -0
  41. package/dist/react/blocks/message.d.ts +14 -0
  42. package/dist/react/blocks/message.js +5 -0
  43. package/dist/react/blocks/quote.d.ts +9 -0
  44. package/dist/react/blocks/quote.js +5 -0
  45. package/dist/react/blocks/spacer.d.ts +8 -0
  46. package/dist/react/blocks/spacer.js +5 -0
  47. package/dist/react/blocks/story.d.ts +17 -0
  48. package/dist/react/blocks/story.js +7 -0
  49. package/dist/react/blocks/text.d.ts +9 -0
  50. package/dist/react/blocks/text.js +5 -0
  51. package/dist/react/blocks/video.d.ts +17 -0
  52. package/dist/react/blocks/video.js +56 -0
  53. package/dist/react/components/Facets.d.ts +21 -0
  54. package/dist/react/components/Facets.js +220 -0
  55. package/dist/react/components/Icon.d.ts +12 -0
  56. package/dist/react/components/Icon.js +66 -0
  57. package/dist/react/components/PaymentIcons.d.ts +5 -0
  58. package/dist/react/components/PaymentIcons.generated.d.ts +22 -0
  59. package/dist/react/components/PaymentIcons.generated.js +21 -0
  60. package/dist/react/components/PaymentIcons.js +18 -0
  61. package/dist/react/components/Price.d.ts +24 -0
  62. package/dist/react/components/Price.js +9 -0
  63. package/dist/react/components/ProductBadges.d.ts +24 -0
  64. package/dist/react/components/ProductBadges.js +57 -0
  65. package/dist/react/components/ProductCard.d.ts +17 -0
  66. package/dist/react/components/ProductCard.js +202 -0
  67. package/dist/react/components/Rating.d.ts +11 -0
  68. package/dist/react/components/Rating.js +35 -0
  69. package/dist/react/components/SwatchStyle.d.ts +23 -0
  70. package/dist/react/components/SwatchStyle.js +22 -0
  71. package/dist/react/components/VideoModal.d.ts +55 -0
  72. package/dist/react/components/VideoModal.js +102 -0
  73. package/dist/react/engine/CssVariables.d.ts +1 -0
  74. package/dist/react/engine/CssVariables.js +105 -0
  75. package/dist/react/engine/context.d.ts +43 -0
  76. package/dist/react/engine/context.js +45 -0
  77. package/dist/react/engine/fonts.d.ts +15 -0
  78. package/dist/react/engine/fonts.js +33 -0
  79. package/dist/react/engine/images.d.ts +24 -0
  80. package/dist/react/engine/images.js +40 -0
  81. package/dist/react/engine/render.d.ts +29 -0
  82. package/dist/react/engine/render.js +87 -0
  83. package/dist/react/engine/settings.d.ts +8 -0
  84. package/dist/react/engine/settings.js +68 -0
  85. package/dist/react/engine/store.d.ts +64 -0
  86. package/dist/react/engine/store.js +42 -0
  87. package/dist/react/engine/translate.d.ts +7 -0
  88. package/dist/react/engine/translate.js +33 -0
  89. package/dist/react/engine/types.d.ts +128 -0
  90. package/dist/react/engine/types.js +1 -0
  91. package/dist/react/index.d.ts +38 -0
  92. package/dist/react/index.js +39 -0
  93. package/dist/react/registries.d.ts +10 -0
  94. package/dist/react/registries.js +68 -0
  95. package/dist/react/registries.server.d.ts +2 -0
  96. package/dist/react/registries.server.js +98 -0
  97. package/dist/react/sections/404.d.ts +10 -0
  98. package/dist/react/sections/404.js +10 -0
  99. package/dist/react/sections/announcement-bar.d.ts +14 -0
  100. package/dist/react/sections/announcement-bar.js +84 -0
  101. package/dist/react/sections/article.d.ts +17 -0
  102. package/dist/react/sections/article.js +31 -0
  103. package/dist/react/sections/contact-form.d.ts +16 -0
  104. package/dist/react/sections/contact-form.js +8 -0
  105. package/dist/react/sections/custom-liquid.d.ts +14 -0
  106. package/dist/react/sections/custom-liquid.js +7 -0
  107. package/dist/react/sections/custom-section.d.ts +14 -0
  108. package/dist/react/sections/custom-section.js +9 -0
  109. package/dist/react/sections/featured-collection.d.ts +10 -0
  110. package/dist/react/sections/featured-collection.js +23 -0
  111. package/dist/react/sections/hero.d.ts +6 -0
  112. package/dist/react/sections/hero.js +35 -0
  113. package/dist/react/sections/image-with-text.d.ts +13 -0
  114. package/dist/react/sections/image-with-text.js +9 -0
  115. package/dist/react/sections/multicolumn.d.ts +15 -0
  116. package/dist/react/sections/multicolumn.js +8 -0
  117. package/dist/react/sections/page.d.ts +13 -0
  118. package/dist/react/sections/page.js +11 -0
  119. package/dist/react/sections/password.d.ts +15 -0
  120. package/dist/react/sections/password.js +9 -0
  121. package/dist/react/sections/rich-text.d.ts +12 -0
  122. package/dist/react/sections/rich-text.js +7 -0
  123. package/dist/react/sections/stories.d.ts +18 -0
  124. package/dist/react/sections/stories.js +10 -0
  125. package/dist/react/sections/video-bubble.d.ts +13 -0
  126. package/dist/react/sections/video-bubble.js +54 -0
  127. package/dist/react/sections/video-carousel.d.ts +19 -0
  128. package/dist/react/sections/video-carousel.js +9 -0
  129. package/dist/react/server.d.ts +7 -0
  130. package/dist/react/server.js +42 -0
  131. package/dist/schemas/bridge.d.ts +98 -0
  132. package/dist/schemas/bridge.js +50 -0
  133. package/dist/schemas/data.d.ts +54 -0
  134. package/dist/schemas/data.js +22 -0
  135. package/dist/schemas/index.d.ts +14 -0
  136. package/dist/schemas/index.js +14 -0
  137. package/dist/schemas/manifest.d.ts +63 -0
  138. package/dist/schemas/manifest.js +32 -0
  139. package/dist/schemas/schema.d.ts +159 -0
  140. package/dist/schemas/schema.js +129 -0
  141. package/dist/schemas/validate.d.ts +22 -0
  142. package/dist/schemas/validate.js +199 -0
  143. package/package.json +84 -0
  144. package/src/commerce/countdown.ts +38 -0
  145. package/src/commerce/events.ts +43 -0
  146. package/src/commerce/facets.ts +88 -0
  147. package/src/commerce/index.ts +11 -0
  148. package/src/commerce/money.ts +120 -0
  149. package/src/commerce/product.ts +177 -0
  150. package/src/editor/frame.ts +305 -0
  151. package/src/editor/host.ts +101 -0
  152. package/src/editor/index.ts +9 -0
  153. package/src/react/adapter.tsx +83 -0
  154. package/src/react/blocks/_box.tsx +37 -0
  155. package/src/react/blocks/_slide.tsx +96 -0
  156. package/src/react/blocks/_video-card.tsx +131 -0
  157. package/src/react/blocks/accordion.tsx +33 -0
  158. package/src/react/blocks/button.tsx +36 -0
  159. package/src/react/blocks/countdown.tsx +105 -0
  160. package/src/react/blocks/custom-liquid.tsx +21 -0
  161. package/src/react/blocks/group.tsx +32 -0
  162. package/src/react/blocks/image.tsx +29 -0
  163. package/src/react/blocks/message.tsx +28 -0
  164. package/src/react/blocks/quote.tsx +24 -0
  165. package/src/react/blocks/spacer.tsx +17 -0
  166. package/src/react/blocks/story.tsx +40 -0
  167. package/src/react/blocks/text.tsx +24 -0
  168. package/src/react/blocks/video.tsx +132 -0
  169. package/src/react/components/Facets.tsx +424 -0
  170. package/src/react/components/Icon.tsx +96 -0
  171. package/src/react/components/PaymentIcons.generated.ts +24 -0
  172. package/src/react/components/PaymentIcons.tsx +27 -0
  173. package/src/react/components/Price.tsx +72 -0
  174. package/src/react/components/ProductBadges.tsx +95 -0
  175. package/src/react/components/ProductCard.tsx +417 -0
  176. package/src/react/components/Rating.tsx +59 -0
  177. package/src/react/components/SwatchStyle.tsx +50 -0
  178. package/src/react/components/VideoModal.tsx +236 -0
  179. package/src/react/engine/CssVariables.tsx +142 -0
  180. package/src/react/engine/context.tsx +108 -0
  181. package/src/react/engine/fonts.ts +42 -0
  182. package/src/react/engine/images.tsx +58 -0
  183. package/src/react/engine/render.tsx +190 -0
  184. package/src/react/engine/settings.ts +89 -0
  185. package/src/react/engine/store.ts +103 -0
  186. package/src/react/engine/translate.ts +40 -0
  187. package/src/react/engine/types.ts +142 -0
  188. package/src/react/index.ts +78 -0
  189. package/src/react/registries.server.ts +110 -0
  190. package/src/react/registries.ts +80 -0
  191. package/src/react/sections/404.tsx +37 -0
  192. package/src/react/sections/announcement-bar.tsx +170 -0
  193. package/src/react/sections/article.tsx +97 -0
  194. package/src/react/sections/contact-form.tsx +104 -0
  195. package/src/react/sections/custom-liquid.tsx +39 -0
  196. package/src/react/sections/custom-section.tsx +44 -0
  197. package/src/react/sections/featured-collection.tsx +89 -0
  198. package/src/react/sections/hero.tsx +84 -0
  199. package/src/react/sections/image-with-text.tsx +61 -0
  200. package/src/react/sections/multicolumn.tsx +46 -0
  201. package/src/react/sections/page.tsx +43 -0
  202. package/src/react/sections/password.tsx +60 -0
  203. package/src/react/sections/rich-text.tsx +37 -0
  204. package/src/react/sections/stories.tsx +51 -0
  205. package/src/react/sections/video-bubble.tsx +145 -0
  206. package/src/react/sections/video-carousel.tsx +57 -0
  207. package/src/react/server.ts +60 -0
  208. package/src/schemas/bridge.ts +143 -0
  209. package/src/schemas/data.ts +72 -0
  210. package/src/schemas/index.ts +14 -0
  211. package/src/schemas/manifest.ts +85 -0
  212. package/src/schemas/schema.ts +287 -0
  213. package/src/schemas/validate.ts +302 -0
@@ -0,0 +1,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
+ }