@akinon/pz-theme 2.0.37-rc.0 → 2.0.37

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 (78) hide show
  1. package/CHANGELOG.md +7 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,1433 @@
1
+ 'use client';
2
+
3
+ import React, { useRef } from 'react';
4
+ import NextLink from 'next/link';
5
+ import { useGetWidgetQuery } from '@akinon/next/data/client/misc';
6
+ import {
7
+ useAddProductMutation,
8
+ useGetProductByPkQuery
9
+ } from '@akinon/next/data/client/product';
10
+ import { useLocalization } from '@akinon/next/hooks';
11
+ import { useOverlay } from '../hooks/use-overlay';
12
+ import { getResponsiveValue, partAttrs } from '../utils';
13
+ import { BlockRendererProps } from './block-renderer-registry';
14
+ import { hasItemTemplate, ItemTemplateList } from './item-template';
15
+ import { DESIGNER_SAMPLE_PRODUCT } from './item-template-utils';
16
+ import {
17
+ formatListingPrice,
18
+ listingPlaceholderStyle,
19
+ readToggle
20
+ } from './listing-context';
21
+ import {
22
+ isProductInStock,
23
+ isVariantOptionSoldOut,
24
+ parsePriceValue,
25
+ pickProductField,
26
+ ThemeProduct,
27
+ ThemeVariantGroup,
28
+ ThemeVariantOption,
29
+ useThemeProduct
30
+ } from './product-block-utils';
31
+ import {
32
+ DEFAULT_RECENTLY_VIEWED_KEY,
33
+ deferIdleTask,
34
+ readRecentlyViewed,
35
+ recordRecentlyViewed,
36
+ RecentlyViewedEntry
37
+ } from './recently-viewed-store';
38
+
39
+ /** Fallback track gap; the card width is derived from whatever is in use. */
40
+ const DEFAULT_CARD_GAP = 16;
41
+
42
+ const ALLOWED_TITLE_TAGS = ['div', 'h2', 'h3', 'span'];
43
+ const ALLOWED_IMAGE_RATIOS = ['', '1/1', '3/4', '4/3'];
44
+ /** `collection` is the source the block has always had, so it is the default. */
45
+ const ALLOWED_SOURCES = ['collection', 'recently-viewed'];
46
+ const ALLOWED_QUICK_VIEW_ICONS = ['eye', 'plus', 'none'];
47
+
48
+ const DEFAULT_QUICK_VIEW_FEEDBACK = 2000;
49
+
50
+ const clampNumber = (
51
+ value: unknown,
52
+ fallback: number,
53
+ min: number,
54
+ max: number
55
+ ) => {
56
+ const numeric = Math.round(Number(value));
57
+ if (!Number.isFinite(numeric)) return fallback;
58
+ return Math.min(max, Math.max(min, numeric));
59
+ };
60
+
61
+ const toText = (value: unknown): string =>
62
+ typeof value === 'string'
63
+ ? value
64
+ : typeof value === 'number' && Number.isFinite(value)
65
+ ? String(value)
66
+ : '';
67
+
68
+ /**
69
+ * A commerce product id, or undefined. The value is a merchant payload on its
70
+ * way into an endpoint path, so anything that is not a positive integer is
71
+ * refused rather than coerced: `pk` also feeds the React key fallback
72
+ * ("recommendation-3"), and a string like that appended to `/product/…/`
73
+ * would be a request built out of untrusted text.
74
+ */
75
+ const toProductPk = (value: unknown): number | undefined => {
76
+ const numeric = Number(value);
77
+
78
+ return Number.isFinite(numeric) && numeric > 0
79
+ ? Math.trunc(numeric)
80
+ : undefined;
81
+ };
82
+
83
+ /**
84
+ * A merchant URL that is safe to put in an `href` or an `src`. Schemes that can
85
+ * execute (`javascript:`, `data:text/html`) are dropped rather than escaped —
86
+ * escaping does nothing to a URL the browser will run on click. Relative and
87
+ * protocol-relative paths carry no scheme to abuse, so they pass through.
88
+ */
89
+ const safeUrl = (value: unknown): string => {
90
+ const text = toText(value).trim();
91
+ if (!text) return '';
92
+ if (/^[/?#]/.test(text)) return text;
93
+
94
+ return /^https?:\/\//i.test(text) ? text : '';
95
+ };
96
+
97
+ const ChevronIcon = ({ direction }: { direction: 'prev' | 'next' }) => (
98
+ <svg
99
+ width="16"
100
+ height="16"
101
+ viewBox="0 0 24 24"
102
+ fill="none"
103
+ stroke="currentColor"
104
+ strokeWidth="1.8"
105
+ strokeLinecap="round"
106
+ strokeLinejoin="round"
107
+ aria-hidden="true"
108
+ >
109
+ <path d={direction === 'prev' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7'} />
110
+ </svg>
111
+ );
112
+
113
+ const QuickViewIcon = ({ name }: { name: string }) => {
114
+ if (name === 'none') return null;
115
+
116
+ return (
117
+ <svg
118
+ width="16"
119
+ height="16"
120
+ viewBox="0 0 24 24"
121
+ fill="none"
122
+ stroke="currentColor"
123
+ strokeWidth="1.8"
124
+ strokeLinecap="round"
125
+ strokeLinejoin="round"
126
+ aria-hidden="true"
127
+ >
128
+ {name === 'plus' ? (
129
+ <path d="M12 5v14M5 12h14" />
130
+ ) : (
131
+ <>
132
+ <path d="M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6-10-6-10-6z" />
133
+ <circle cx="12" cy="12" r="2.6" />
134
+ </>
135
+ )}
136
+ </svg>
137
+ );
138
+ };
139
+
140
+ const CloseIcon = () => (
141
+ <svg
142
+ width="18"
143
+ height="18"
144
+ viewBox="0 0 24 24"
145
+ fill="none"
146
+ stroke="currentColor"
147
+ strokeWidth="1.8"
148
+ strokeLinecap="round"
149
+ aria-hidden="true"
150
+ >
151
+ <path d="M6 6l12 12M18 6L6 18" />
152
+ </svg>
153
+ );
154
+
155
+ interface RecommendationCard {
156
+ key: string;
157
+ /**
158
+ * The commerce product id. `key` falls back to an index ("recommendation-3")
159
+ * so it can never address an endpoint; this can. It is also what opens the
160
+ * row's item scope — see `resolveItemProduct()` — so a favourite or
161
+ * add-to-cart block inside an authored card acts on the card's own product
162
+ * instead of falling through to the page's.
163
+ */
164
+ pk?: number;
165
+ name: string;
166
+ url: string;
167
+ image: string;
168
+ price: string;
169
+ }
170
+
171
+ /**
172
+ * Functional PDP/campaign block: a related-products slider.
173
+ *
174
+ * v1 core ships no recommendation endpoint, so the data path is the
175
+ * collection/widget one every storefront already uses: the author names a
176
+ * collection and `getWidget` returns its products in merchandiser order. The
177
+ * cards are laid out exactly like the product grid so a recommendation strip
178
+ * and a PLP card stay visually identical.
179
+ *
180
+ * `source` picks where the cards come from. It defaults to `collection` — the
181
+ * only behaviour that existed — and `recently-viewed` swaps in the products
182
+ * this browser has looked at, which the block also records (see
183
+ * `recently-viewed-store.ts` for what is stored and why).
184
+ *
185
+ * ACTING ON A CARD WITHOUT LEAVING THE PAGE comes in two halves, and only one
186
+ * of them is built here.
187
+ *
188
+ * Adding to basket from a card was already reachable and is not rebuilt: a
189
+ * designer marks a child as the card layout and drops the existing add-to-cart
190
+ * or favourite block into it, and `ItemTemplateList` wraps every row in the
191
+ * item scope so those blocks act on the ROW's product. What that mechanism was
192
+ * missing is the id — the card model published `key` (which falls back to an
193
+ * index) and no `pk`, so `resolveItemProduct()` refused the scope and the
194
+ * blocks silently fell through to the page's product. Publishing `pk` is what
195
+ * turns the existing mechanism on.
196
+ *
197
+ * The overlay is the half that did not exist. A recommendation row carries a
198
+ * SUMMARY — the collection/widget payload and the stored history both stop at
199
+ * name, url, image, price — and no variant matrix, so there is nothing to pick
200
+ * a size from. Quick view therefore fetches `/product/{pk}/` on demand, for the
201
+ * one card the shopper opened, and shows the summary it already has while that
202
+ * request is in flight. Off by default; every published strip renders exactly
203
+ * as it did.
204
+ *
205
+ * Parts: title, arrow-list, arrow, track, card-slot, card, card-image,
206
+ * card-name, card-price, quick-view-trigger, quick-view-backdrop,
207
+ * quick-view-panel, quick-view-close, quick-view-media, quick-view-body,
208
+ * quick-view-name, quick-view-price, quick-view-old-price, quick-view-status,
209
+ * quick-view-retry, quick-view-size-label, quick-view-size-list,
210
+ * quick-view-size, quick-view-add, quick-view-link.
211
+ * Properties: source, title, titleTag, collectionId, limit, columnsDesktop,
212
+ * columnsMobile, cardGap, showName, showPrice, imageRatio, showArrows,
213
+ * prevLabel, nextLabel, recentlyViewedKey, recentlyViewedCapacity,
214
+ * recentlyViewedMaxAge, recentlyViewedEmptyLabel, showQuickView,
215
+ * quickViewTriggerLabel, quickViewTriggerIcon, quickViewWidth,
216
+ * showQuickViewBackdrop, quickViewCloseOnBackdrop, quickViewCloseOnEscape,
217
+ * quickViewLockScroll, showQuickViewClose, quickViewCloseLabel,
218
+ * quickViewLoadingLabel, quickViewErrorLabel, quickViewRetryLabel,
219
+ * quickViewSizeLabel, quickViewSelectSizeLabel, quickViewAddLabel,
220
+ * quickViewAddingLabel, quickViewAddedLabel, quickViewAddErrorLabel,
221
+ * quickViewOutOfStockLabel, quickViewFeedbackDuration, showQuickViewLink,
222
+ * quickViewLinkLabel.
223
+ */
224
+ const ProductRecommendationsBlock = ({
225
+ block,
226
+ placeholderId,
227
+ sectionId,
228
+ selectedBlockId = null,
229
+ currentBreakpoint = 'desktop',
230
+ isDesigner = false
231
+ }: BlockRendererProps) => {
232
+ const { locale, currency: localeCurrency } = useLocalization();
233
+ const rootRef = useRef<HTMLDivElement>(null);
234
+ const trackRef = useRef<HTMLDivElement>(null);
235
+ // The ambient product: on a PDP this is what the shopper is looking at, and
236
+ // it is both what gets recorded and what is excluded from its own strip.
237
+ const ambientProduct = useThemeProduct();
238
+ const [recentEntries, setRecentEntries] = React.useState<
239
+ RecentlyViewedEntry[]
240
+ >([]);
241
+ /** The card whose overlay is open, or null. Closed on load, designer too. */
242
+ const [quickViewCard, setQuickViewCard] =
243
+ React.useState<RecommendationCard | null>(null);
244
+ /** The variant option value the shopper picked, '' until they pick one. */
245
+ const [chosenOption, setChosenOption] = React.useState('');
246
+ const [addFeedback, setAddFeedback] = React.useState<
247
+ 'idle' | 'added' | 'error'
248
+ >('idle');
249
+ // The element that opened the overlay, so focus can be handed back to the
250
+ // exact card the shopper came from rather than to the top of the strip.
251
+ const quickViewOpenerRef = useRef<HTMLElement | null>(null);
252
+ const addFeedbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
253
+
254
+ const prop = (key: string, fallback: string) =>
255
+ String(
256
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
257
+ );
258
+
259
+ const sourceValue = prop('source', 'collection');
260
+ const source = ALLOWED_SOURCES.includes(sourceValue)
261
+ ? sourceValue
262
+ : 'collection';
263
+ const isRecentlyViewed = source === 'recently-viewed';
264
+
265
+ const title = String(
266
+ getResponsiveValue(block.properties?.title, currentBreakpoint, '')
267
+ ).trim();
268
+ const titleTagValue = prop('titleTag', 'div');
269
+ const TitleTag = (
270
+ ALLOWED_TITLE_TAGS.includes(titleTagValue) ? titleTagValue : 'div'
271
+ ) as React.ElementType;
272
+ const collectionId = String(
273
+ getResponsiveValue(block.properties?.collectionId, currentBreakpoint, '')
274
+ ).trim();
275
+ const limit = clampNumber(
276
+ getResponsiveValue(block.properties?.limit, currentBreakpoint, 8),
277
+ 8,
278
+ 1,
279
+ 24
280
+ );
281
+ const columnsDesktop = clampNumber(
282
+ getResponsiveValue(block.properties?.columnsDesktop, currentBreakpoint, 4),
283
+ 4,
284
+ 2,
285
+ 6
286
+ );
287
+ const columnsMobile = clampNumber(
288
+ getResponsiveValue(block.properties?.columnsMobile, currentBreakpoint, 2),
289
+ 2,
290
+ 1,
291
+ 3
292
+ );
293
+ const cardGap = clampNumber(
294
+ getResponsiveValue(
295
+ block.properties?.cardGap,
296
+ currentBreakpoint,
297
+ DEFAULT_CARD_GAP
298
+ ),
299
+ DEFAULT_CARD_GAP,
300
+ 0,
301
+ 64
302
+ );
303
+ const showName = readToggle(
304
+ block.properties?.showName,
305
+ currentBreakpoint,
306
+ true
307
+ );
308
+ const showPrice = readToggle(
309
+ block.properties?.showPrice,
310
+ currentBreakpoint,
311
+ true
312
+ );
313
+ const imageRatioValue = prop('imageRatio', '');
314
+ const imageRatio = ALLOWED_IMAGE_RATIOS.includes(imageRatioValue)
315
+ ? imageRatioValue
316
+ : '';
317
+ const showArrows = readToggle(
318
+ block.properties?.showArrows,
319
+ currentBreakpoint,
320
+ false
321
+ );
322
+ const prevLabel = prop('prevLabel', 'Previous products');
323
+ const nextLabel = prop('nextLabel', 'Next products');
324
+ const recentlyViewedKey =
325
+ prop('recentlyViewedKey', DEFAULT_RECENTLY_VIEWED_KEY).trim() ||
326
+ DEFAULT_RECENTLY_VIEWED_KEY;
327
+ const recentlyViewedCapacity = clampNumber(
328
+ getResponsiveValue(
329
+ block.properties?.recentlyViewedCapacity,
330
+ currentBreakpoint,
331
+ 20
332
+ ),
333
+ 20,
334
+ 1,
335
+ 60
336
+ );
337
+ const recentlyViewedMaxAge = clampNumber(
338
+ getResponsiveValue(
339
+ block.properties?.recentlyViewedMaxAge,
340
+ currentBreakpoint,
341
+ 30
342
+ ),
343
+ 30,
344
+ 0,
345
+ 365
346
+ );
347
+ const recentlyViewedEmptyLabel = prop(
348
+ 'recentlyViewedEmptyLabel',
349
+ 'Recently viewed'
350
+ );
351
+
352
+ // Off by default: a published strip has never drawn a trigger over its cards
353
+ // and must keep not drawing one.
354
+ const showQuickView = readToggle(
355
+ block.properties?.showQuickView,
356
+ currentBreakpoint,
357
+ false
358
+ );
359
+ const quickViewTriggerLabel = prop('quickViewTriggerLabel', 'Quick view');
360
+ const quickViewTriggerIconValue = prop('quickViewTriggerIcon', 'eye');
361
+ const quickViewTriggerIcon = ALLOWED_QUICK_VIEW_ICONS.includes(
362
+ quickViewTriggerIconValue
363
+ )
364
+ ? quickViewTriggerIconValue
365
+ : 'eye';
366
+ const quickViewWidth = clampNumber(
367
+ getResponsiveValue(block.properties?.quickViewWidth, currentBreakpoint, 720),
368
+ 720,
369
+ 280,
370
+ 1200
371
+ );
372
+ const showQuickViewBackdrop = readToggle(
373
+ block.properties?.showQuickViewBackdrop,
374
+ currentBreakpoint,
375
+ true
376
+ );
377
+ const quickViewCloseOnBackdrop = readToggle(
378
+ block.properties?.quickViewCloseOnBackdrop,
379
+ currentBreakpoint,
380
+ true
381
+ );
382
+ const quickViewCloseOnEscape = readToggle(
383
+ block.properties?.quickViewCloseOnEscape,
384
+ currentBreakpoint,
385
+ true
386
+ );
387
+ const quickViewLockScroll = readToggle(
388
+ block.properties?.quickViewLockScroll,
389
+ currentBreakpoint,
390
+ true
391
+ );
392
+ const showQuickViewClose = readToggle(
393
+ block.properties?.showQuickViewClose,
394
+ currentBreakpoint,
395
+ true
396
+ );
397
+ const quickViewCloseLabel = prop('quickViewCloseLabel', 'Close');
398
+ const quickViewLoadingLabel = prop('quickViewLoadingLabel', 'Loading…');
399
+ const quickViewErrorLabel = prop(
400
+ 'quickViewErrorLabel',
401
+ 'This product could not be loaded.'
402
+ );
403
+ const quickViewRetryLabel = prop('quickViewRetryLabel', 'Try again');
404
+ const quickViewSizeLabel = prop('quickViewSizeLabel', 'Size');
405
+ const quickViewSelectSizeLabel = prop(
406
+ 'quickViewSelectSizeLabel',
407
+ 'Select a size'
408
+ );
409
+ const quickViewAddLabel = prop('quickViewAddLabel', 'Add to basket');
410
+ const quickViewAddingLabel = prop('quickViewAddingLabel', 'Adding…');
411
+ const quickViewAddedLabel = prop('quickViewAddedLabel', 'Added');
412
+ const quickViewAddErrorLabel = prop('quickViewAddErrorLabel', 'Try again');
413
+ const quickViewOutOfStockLabel = prop(
414
+ 'quickViewOutOfStockLabel',
415
+ 'Out of stock'
416
+ );
417
+ const quickViewFeedbackDuration = clampNumber(
418
+ getResponsiveValue(
419
+ block.properties?.quickViewFeedbackDuration,
420
+ currentBreakpoint,
421
+ DEFAULT_QUICK_VIEW_FEEDBACK
422
+ ),
423
+ DEFAULT_QUICK_VIEW_FEEDBACK,
424
+ 500,
425
+ 10000
426
+ );
427
+ const showQuickViewLink = readToggle(
428
+ block.properties?.showQuickViewLink,
429
+ currentBreakpoint,
430
+ true
431
+ );
432
+ const quickViewLinkLabel = prop('quickViewLinkLabel', 'View full details');
433
+
434
+ // The hook has to run unconditionally to keep the hook order stable; `skip`
435
+ // is what prevents a request before the author has named a collection — and
436
+ // now also a request for a collection the recently-viewed source never uses.
437
+ const { data, isFetching } = useGetWidgetQuery(collectionId, {
438
+ skip: !collectionId || isRecentlyViewed
439
+ });
440
+
441
+ /**
442
+ * The overlay's own request. A row carries a summary only, so the variant
443
+ * matrix a size picker needs exists nowhere on this page — it has to come
444
+ * from the product endpoint, for the ONE card the shopper opened. Skipped
445
+ * until then, so a strip that nobody touches still costs a single widget
446
+ * call; skipped in the designer, where there is no real product behind a
447
+ * canvas card and a sample stands in instead.
448
+ */
449
+ const quickViewPk = quickViewCard ? toProductPk(quickViewCard.pk) : undefined;
450
+ const {
451
+ data: quickViewDetail,
452
+ isFetching: isQuickViewLoading,
453
+ isError: isQuickViewFailed,
454
+ refetch: refetchQuickView
455
+ } = useGetProductByPkQuery(quickViewPk ?? 0, {
456
+ skip: isDesigner || quickViewPk === undefined
457
+ });
458
+ const [addProduct, { isLoading: isAdding }] = useAddProductMutation();
459
+
460
+ const currentPk = toText(pickProductField(ambientProduct, 'pk'));
461
+ // `pickProductField` already resolves title/name and url/absolute_url, so a
462
+ // page-context product and a normalised listing row both land here.
463
+ const currentName = toText(pickProductField(ambientProduct, 'title'));
464
+ const currentUrl = toText(pickProductField(ambientProduct, 'url'));
465
+ const currentImage = toText(pickProductField(ambientProduct, 'image'));
466
+ const currentPrice = toText(pickProductField(ambientProduct, 'price'));
467
+ const currentCurrency = toText(pickProductField(ambientProduct, 'currency'));
468
+
469
+ /**
470
+ * Reads the history, then records this view off the critical path.
471
+ *
472
+ * Deps are primitives on purpose: the ambient product object is rebuilt on
473
+ * every page-context change, and depending on it would rewrite storage in a
474
+ * loop. In the designer nothing is read, recorded or scheduled — an author
475
+ * clicking through pages must not fill their own browser with history, and
476
+ * `isDesigner` is what keeps the write path off the storefront preview.
477
+ */
478
+ React.useEffect(() => {
479
+ if (!isRecentlyViewed || isDesigner) return;
480
+
481
+ setRecentEntries(
482
+ readRecentlyViewed(recentlyViewedKey, recentlyViewedMaxAge)
483
+ );
484
+
485
+ // Nothing identifies the page's product (a homepage strip, say), so there
486
+ // is a history to read but no view to add.
487
+ if (!currentPk) return;
488
+
489
+ return deferIdleTask(() =>
490
+ recordRecentlyViewed(
491
+ recentlyViewedKey,
492
+ {
493
+ pk: currentPk,
494
+ name: currentName,
495
+ url: currentUrl,
496
+ image: currentImage,
497
+ price: currentPrice,
498
+ currency: currentCurrency,
499
+ viewedAt: Date.now()
500
+ },
501
+ recentlyViewedCapacity
502
+ )
503
+ );
504
+ }, [
505
+ isRecentlyViewed,
506
+ isDesigner,
507
+ recentlyViewedKey,
508
+ recentlyViewedMaxAge,
509
+ recentlyViewedCapacity,
510
+ currentPk,
511
+ currentName,
512
+ currentUrl,
513
+ currentImage,
514
+ currentPrice,
515
+ currentCurrency
516
+ ]);
517
+
518
+ /**
519
+ * Every way out of the overlay is a designer toggle, so a composition can
520
+ * legally have all of them off and seal a shopper inside. Only a POINTER
521
+ * route counts — Escape does not exist on the phone this pattern is used on
522
+ * most — and when none survives, the close button is forced back on and
523
+ * Escape re-armed. An authored composition must never be a trap.
524
+ */
525
+ const hasPointerDismissRoute =
526
+ showQuickViewClose || (showQuickViewBackdrop && quickViewCloseOnBackdrop);
527
+ const quickViewCloseVisible = showQuickViewClose || !hasPointerDismissRoute;
528
+ const escapeClosesQuickView =
529
+ quickViewCloseOnEscape || !hasPointerDismissRoute;
530
+ const quickViewOpen = quickViewCard !== null;
531
+
532
+ const closeQuickView = () => {
533
+ setQuickViewCard(null);
534
+ setChosenOption('');
535
+ setAddFeedback('idle');
536
+ };
537
+
538
+ /**
539
+ * Focus, Escape and the page scroll lock are the shared overlay contract
540
+ * rather than this block's own — and this block is exactly the reason it has
541
+ * to be shared: a strip ships more than once on a product page (a "you may
542
+ * also like" one and a "recently viewed" one), so a per-block focus trap
543
+ * fought its twin over focus and a per-block scroll lock captured the
544
+ * ALREADY LOCKED overflow as "the page's own value".
545
+ *
546
+ * `modal` is what the overlay honestly is: only a backdrop actually covers
547
+ * the page, and without one `aria-modal` would tell assistive tech the
548
+ * opposite of what is true. `manageFocus` stays on either way — moving focus
549
+ * in and handing it back is right for a panel the shopper opened, while the
550
+ * TRAP that backs the `aria-modal` claim is what the hook gates on `modal`.
551
+ */
552
+ const overlay = useOverlay<HTMLDivElement>({
553
+ open: quickViewOpen,
554
+ onClose: closeQuickView,
555
+ isDesigner,
556
+ modal: showQuickViewBackdrop,
557
+ lockScroll: quickViewLockScroll,
558
+ closeOnEscape: escapeClosesQuickView,
559
+ manageFocus: true,
560
+ returnFocusRef: quickViewOpenerRef,
561
+ // The card the shopper came from can be gone by the time the overlay
562
+ // closes (a recently-viewed strip re-reads its history), and the strip
563
+ // beats dropping a keyboard shopper at the top of the document.
564
+ fallbackFocusRef: rootRef
565
+ });
566
+
567
+ React.useEffect(
568
+ () => () => {
569
+ if (addFeedbackTimer.current) clearTimeout(addFeedbackTimer.current);
570
+ },
571
+ []
572
+ );
573
+
574
+ const collectionProducts: RecommendationCard[] = (
575
+ (data?.products || []) as Array<Record<string, unknown>>
576
+ )
577
+ .slice(0, limit)
578
+ // Annotated so the element type matches the predicate below: left inferred,
579
+ // the object literal makes `pk` required while a card's `pk` is optional,
580
+ // and a type predicate must be assignable to what it narrows.
581
+ .map((entry, index): RecommendationCard | null => {
582
+ const name = toText(entry.name) || toText(entry.title);
583
+ const url = toText(entry.absolute_url) || toText(entry.url);
584
+ if (!name && !url) return null;
585
+
586
+ const images = Array.isArray(entry.productimage_set)
587
+ ? (entry.productimage_set as Array<Record<string, unknown>>)
588
+ : [];
589
+ // Commerce prices each product in its own currency; the storefront
590
+ // currency is only the fallback for payloads that omit it.
591
+ const currency = toText(entry.currency_type) || localeCurrency || '';
592
+
593
+ return {
594
+ key: toText(entry.pk) || `recommendation-${index}`,
595
+ pk: toProductPk(entry.pk),
596
+ name,
597
+ url: url || '#',
598
+ image: toText(entry.image) || toText(images[0]?.image),
599
+ price: formatListingPrice(entry.price, currency, locale)
600
+ };
601
+ })
602
+ .filter((card): card is RecommendationCard => card !== null);
603
+
604
+ /**
605
+ * The stored amount is re-formatted here rather than read back as a string,
606
+ * so a shopper who switches locale or currency sees the money in the format
607
+ * the rest of the page uses. The AMOUNT itself is still the one captured at
608
+ * view time — see recently-viewed-store.ts.
609
+ */
610
+ const recentlyViewedProducts: RecommendationCard[] = recentEntries
611
+ // A product must never recommend itself back to the page it is on.
612
+ .filter((entry) => !currentPk || entry.pk !== currentPk)
613
+ .filter((entry) => Boolean(entry.name || entry.url))
614
+ .slice(0, limit)
615
+ .map((entry) => ({
616
+ key: entry.pk,
617
+ pk: toProductPk(entry.pk),
618
+ name: entry.name,
619
+ url: entry.url || '#',
620
+ image: entry.image,
621
+ price: formatListingPrice(
622
+ entry.price,
623
+ entry.currency || localeCurrency || '',
624
+ locale
625
+ )
626
+ }));
627
+
628
+ const products = isRecentlyViewed
629
+ ? recentlyViewedProducts
630
+ : collectionProducts;
631
+
632
+ /**
633
+ * The designer has no browsing history behind the canvas, so a recently
634
+ * viewed strip would collapse to nothing and stop being selectable, sizeable
635
+ * or stylable. Skeleton rows keep the track and every card part on screen.
636
+ * Storefront-side this is null, which is what leaves "empty renders nothing"
637
+ * intact.
638
+ */
639
+ const designerRows: RecommendationCard[] | null =
640
+ isDesigner &&
641
+ isRecentlyViewed &&
642
+ products.length === 0 &&
643
+ // An authored card layout already draws its own placeholder rows
644
+ // (`designerPlaceholderCount`), so adding built-in ones would double them.
645
+ !hasItemTemplate(block, currentBreakpoint)
646
+ ? Array.from(
647
+ { length: Math.min(limit, columnsDesktop + 1) },
648
+ (_, index) => ({
649
+ key: `recently-viewed-placeholder-${index}`,
650
+ name: recentlyViewedEmptyLabel,
651
+ url: '#',
652
+ image: '',
653
+ price: ''
654
+ })
655
+ )
656
+ : null;
657
+
658
+ // A designer who marked a child as the card layout gets that layout repeated
659
+ // per product; otherwise the built-in card renders unchanged.
660
+ const customCards = hasItemTemplate(block, currentBreakpoint) ? (
661
+ <ItemTemplateList
662
+ block={block}
663
+ items={products as unknown as Record<string, unknown>[]}
664
+ placeholderId={placeholderId}
665
+ sectionId={sectionId}
666
+ isDesigner={isDesigner}
667
+ selectedBlockId={selectedBlockId}
668
+ currentBreakpoint={currentBreakpoint}
669
+ designerPlaceholderCount={4}
670
+ />
671
+ ) : null;
672
+
673
+ // An empty history is the normal state on a first visit, so the strip
674
+ // renders nothing at all rather than an empty carousel — `designerRows` is
675
+ // null on the storefront and this returns before any markup is emitted.
676
+ if (products.length === 0 && !customCards && !designerRows) {
677
+ if (!isDesigner) return null;
678
+
679
+ return (
680
+ <div data-product-recommendations="" style={listingPlaceholderStyle}>
681
+ {!collectionId
682
+ ? 'Recommendations (set a collection id)'
683
+ : isFetching
684
+ ? 'Recommendations (loading)'
685
+ : 'Recommendations (no products in this collection)'}
686
+ </div>
687
+ );
688
+ }
689
+
690
+ // Column count is a block PROPERTY, and properties resolve with the designer
691
+ // breakpoint only — on the live storefront currentBreakpoint is always
692
+ // 'desktop'. A scoped rule with the same 1023px cutoff the theme CSS
693
+ // generator uses keeps real mobile viewports in sync with the editor.
694
+ const trackId = String(block.id || 'recommendations').replace(
695
+ /[^a-zA-Z0-9_-]/g,
696
+ ''
697
+ );
698
+ const cardWidth = (columns: number) =>
699
+ `calc((100% - ${(columns - 1) * cardGap}px) / ${columns})`;
700
+
701
+ const scrollTrack = (direction: -1 | 1) => {
702
+ const track = trackRef.current;
703
+ if (!track) return;
704
+ track.scrollBy({
705
+ left: direction * track.clientWidth,
706
+ behavior: 'smooth'
707
+ });
708
+ };
709
+
710
+ const arrowStyle: React.CSSProperties = {
711
+ display: 'inline-flex',
712
+ alignItems: 'center',
713
+ justifyContent: 'center',
714
+ width: '32px',
715
+ height: '32px',
716
+ padding: 0,
717
+ border: 'none',
718
+ borderRadius: '50%',
719
+ background:
720
+ 'var(--product-recommendations-arrow-bg, rgba(255, 255, 255, 0.85))',
721
+ color: 'inherit',
722
+ cursor: 'pointer'
723
+ };
724
+
725
+ /**
726
+ * What the overlay draws. The row's own summary goes up immediately and the
727
+ * fetched detail takes over as it lands, so opening a card never answers
728
+ * with an empty panel over a slow connection. In the designer the sample
729
+ * product stands in for one the canvas cannot have — without it an author
730
+ * would switch quick view on and find nothing to style.
731
+ */
732
+ const quickViewProduct: ThemeProduct | null = isDesigner
733
+ ? DESIGNER_SAMPLE_PRODUCT
734
+ : quickViewDetail?.product
735
+ ? // Through `unknown` deliberately: commerce sends `attributes` as a map
736
+ // while ThemeProduct models it as a list, so the two do not overlap
737
+ // enough for a direct assertion. Nothing on this path reads
738
+ // `attributes` — the fields quick view shows (title, price, images,
739
+ // variants, stock) agree in both shapes.
740
+ (({
741
+ ...(quickViewDetail.product as Record<string, unknown>),
742
+ variants: quickViewDetail.variants,
743
+ selectedVariant: quickViewDetail.selected_variant ?? null,
744
+ in_stock: quickViewDetail.in_stock
745
+ } as unknown) as ThemeProduct)
746
+ : null;
747
+
748
+ const quickViewName =
749
+ quickViewCard?.name ||
750
+ toText(pickProductField(quickViewProduct, 'title')) ||
751
+ '';
752
+ const quickViewImage =
753
+ safeUrl(pickProductField(quickViewProduct, 'image')) ||
754
+ safeUrl(quickViewCard?.image);
755
+ const quickViewHref = safeUrl(quickViewCard?.url);
756
+ // Commerce prices each product in its own currency; the storefront currency
757
+ // is only the fallback for payloads that omit it.
758
+ const quickViewCurrency =
759
+ toText(pickProductField(quickViewProduct, 'currency')) ||
760
+ localeCurrency ||
761
+ '';
762
+ const quickViewAmount = pickProductField(quickViewProduct, 'price');
763
+ const quickViewPrice =
764
+ quickViewAmount === undefined
765
+ ? quickViewCard?.price || ''
766
+ : formatListingPrice(quickViewAmount, quickViewCurrency, locale);
767
+ const quickViewRetail = parsePriceValue(
768
+ pickProductField(quickViewProduct, 'retail_price')
769
+ );
770
+ const quickViewCurrent = parsePriceValue(quickViewAmount);
771
+ // Nothing is derived: a struck price is only drawn when commerce actually
772
+ // sent a higher one, never computed from a discount the payload implies.
773
+ const quickViewOldPrice =
774
+ quickViewRetail !== null &&
775
+ quickViewCurrent !== null &&
776
+ quickViewRetail > quickViewCurrent
777
+ ? formatListingPrice(quickViewRetail, quickViewCurrency, locale)
778
+ : '';
779
+
780
+ const variantGroups = (
781
+ pickProductField<ThemeVariantGroup[]>(quickViewProduct, 'variants') || []
782
+ ).filter(
783
+ (group) => Array.isArray(group?.options) && group.options.length > 0
784
+ );
785
+ /**
786
+ * Commerce orders variant groups from the most general to the most specific,
787
+ * and only the LAST group's options are sellable leaves — each carries its
788
+ * own product, and that product's pk is what goes in the basket. An earlier
789
+ * group (colour) is a different product altogether, so picking there is a
790
+ * navigation and belongs on the product page, not in an overlay that adds.
791
+ */
792
+ const sizeGroup = variantGroups[variantGroups.length - 1] || null;
793
+ const sizeOptions: ThemeVariantOption[] = sizeGroup?.options || [];
794
+ /**
795
+ * The option the shopper actually picked.
796
+ *
797
+ * The empty-string guard is the whole point: `chosenOption` starts '' and an
798
+ * option whose payload carries no `value` also reads '', so an unguarded
799
+ * match selected that leaf on the first render — before the shopper had
800
+ * touched anything. Nothing showed as pressed (the size button has always
801
+ * guarded `value !== ''`), `needsSize` went false, and the add button posted
802
+ * a size nobody chose. The button's guard and this one have to be the same
803
+ * guard, because they are answering the same question.
804
+ */
805
+ const chosenSize =
806
+ (chosenOption
807
+ ? sizeOptions.find((option) => toText(option?.value) === chosenOption)
808
+ : null) || null;
809
+ // Commerce names the attribute ("Beden", "Numara"); the authored label is
810
+ // the fallback for a payload that did not, never an override of it.
811
+ const sizeGroupLabel =
812
+ toText(sizeGroup?.attribute_name) || quickViewSizeLabel;
813
+
814
+ const quickViewStatus = isQuickViewLoading
815
+ ? quickViewLoadingLabel
816
+ : isQuickViewFailed
817
+ ? quickViewErrorLabel
818
+ : '';
819
+ // The summary carries no stock and no variants, so adding before the detail
820
+ // lands would put a parent product in the basket for something that is only
821
+ // sellable by size. The button waits.
822
+ const isQuickViewReady = quickViewProduct !== null;
823
+ /**
824
+ * The stock of the thing that would actually go in the basket.
825
+ *
826
+ * A card carries ONE pk while the overlay sells whatever LEAF the shopper
827
+ * picked, so asking the card's product answered about a different product in
828
+ * both directions. A card pointing at a size that had sold through declared
829
+ * a product with two sellable sizes dead — the everyday state after a size
830
+ * sells out — and a parent commerce still called in stock let a sold-out
831
+ * leaf reach the add endpoint.
832
+ *
833
+ * With nothing picked yet the parent's own flag still says nothing about the
834
+ * sizes under it, so only "every size is gone" can honestly answer sold out;
835
+ * anything else is `needsSize`, and the button asks for a size.
836
+ */
837
+ const quickViewInStock = sizeGroup
838
+ ? chosenSize
839
+ ? !isVariantOptionSoldOut(chosenSize)
840
+ : !sizeOptions.every((option) => isVariantOptionSoldOut(option))
841
+ : isProductInStock(quickViewProduct);
842
+ const needsSize = sizeGroup !== null && chosenSize === null;
843
+ const quickViewAddPk = sizeGroup
844
+ ? toProductPk(chosenSize?.product?.pk)
845
+ : toProductPk(pickProductField(quickViewProduct, 'pk')) ||
846
+ toProductPk(quickViewCard?.pk);
847
+ const quickViewAddDisabled =
848
+ !isQuickViewReady ||
849
+ isAdding ||
850
+ !quickViewInStock ||
851
+ needsSize ||
852
+ // The designer has no real id behind the sample, and a permanently dead
853
+ // button is not something an author can judge their styling against.
854
+ (!isDesigner && quickViewAddPk === undefined);
855
+
856
+ const quickViewAddText = !isQuickViewReady
857
+ ? // Nothing is known yet, and "Out of stock" is a claim rather than a
858
+ // silence — the status line is what reports the wait.
859
+ quickViewAddLabel
860
+ : !quickViewInStock
861
+ ? quickViewOutOfStockLabel
862
+ : isAdding
863
+ ? quickViewAddingLabel
864
+ : addFeedback === 'added'
865
+ ? quickViewAddedLabel
866
+ : addFeedback === 'error'
867
+ ? quickViewAddErrorLabel
868
+ : needsSize
869
+ ? quickViewSelectSizeLabel
870
+ : quickViewAddLabel;
871
+
872
+ const openQuickView = (
873
+ card: RecommendationCard,
874
+ trigger: HTMLElement | null
875
+ ) => {
876
+ quickViewOpenerRef.current = trigger;
877
+ setChosenOption('');
878
+ setAddFeedback('idle');
879
+ setQuickViewCard(card);
880
+ };
881
+
882
+ const handleQuickViewAdd = async () => {
883
+ // A click inside the designer must never reach the shopper's real basket,
884
+ // and the feedback timer below must never run on the canvas.
885
+ if (isDesigner || quickViewAddDisabled || quickViewAddPk === undefined) {
886
+ return;
887
+ }
888
+
889
+ try {
890
+ await addProduct({
891
+ product: quickViewAddPk,
892
+ quantity: 1,
893
+ attributes: {}
894
+ }).unwrap();
895
+ setAddFeedback('added');
896
+ } catch {
897
+ setAddFeedback('error');
898
+ }
899
+
900
+ if (addFeedbackTimer.current) clearTimeout(addFeedbackTimer.current);
901
+ addFeedbackTimer.current = setTimeout(
902
+ () => setAddFeedback('idle'),
903
+ quickViewFeedbackDuration
904
+ );
905
+ };
906
+
907
+ const quickViewButtonStyle: React.CSSProperties = {
908
+ border: '1px solid var(--product-recommendations-quick-view-border, #d1d5db)',
909
+ background: 'transparent',
910
+ color: 'inherit',
911
+ font: 'inherit',
912
+ cursor: 'pointer'
913
+ };
914
+
915
+ return (
916
+ <div
917
+ ref={rootRef}
918
+ data-product-recommendations=""
919
+ data-product-recommendations-id={trackId}
920
+ // The overlay's last-resort focus target, and a plain div refuses focus:
921
+ // without this the fallback silently did nothing and a shopper whose
922
+ // trigger card had gone was dropped at the top of the document. `-1`
923
+ // keeps it out of the Tab order, and it is only emitted where the
924
+ // overlay exists at all, so a strip without quick view is untouched.
925
+ tabIndex={showQuickView ? -1 : undefined}
926
+ >
927
+ <style>
928
+ {`[data-product-recommendations-id="${trackId}"] [data-product-recommendations-track]{grid-auto-columns:${cardWidth(
929
+ columnsDesktop
930
+ )};}` +
931
+ `@media (max-width:1023px){[data-product-recommendations-id="${trackId}"] [data-product-recommendations-track]{grid-auto-columns:${cardWidth(
932
+ columnsMobile
933
+ )};}}`}
934
+ </style>
935
+
936
+ {title && (
937
+ <TitleTag
938
+ data-product-recommendations-title=""
939
+ {...partAttrs('title')}
940
+ style={{ marginBottom: '12px', fontWeight: 600 }}
941
+ >
942
+ {title}
943
+ </TitleTag>
944
+ )}
945
+
946
+ {showArrows && (
947
+ <div
948
+ data-product-recommendations-arrows=""
949
+ {...partAttrs('arrow-list')}
950
+ style={{
951
+ display: 'flex',
952
+ justifyContent: 'flex-end',
953
+ gap: '8px',
954
+ marginBottom: '8px'
955
+ }}
956
+ >
957
+ <button
958
+ type="button"
959
+ data-product-recommendations-arrow="prev"
960
+ {...partAttrs('arrow')}
961
+ aria-label={prevLabel}
962
+ onClick={() => scrollTrack(-1)}
963
+ style={arrowStyle}
964
+ >
965
+ <ChevronIcon direction="prev" />
966
+ </button>
967
+ <button
968
+ type="button"
969
+ data-product-recommendations-arrow="next"
970
+ {...partAttrs('arrow')}
971
+ aria-label={nextLabel}
972
+ onClick={() => scrollTrack(1)}
973
+ style={arrowStyle}
974
+ >
975
+ <ChevronIcon direction="next" />
976
+ </button>
977
+ </div>
978
+ )}
979
+
980
+ <div
981
+ ref={trackRef}
982
+ data-product-recommendations-track=""
983
+ {...partAttrs('track')}
984
+ style={{
985
+ display: 'grid',
986
+ gridAutoFlow: 'column',
987
+ gap: `var(--product-recommendations-gap, ${cardGap}px)`,
988
+ overflowX: 'auto',
989
+ scrollSnapType: 'x mandatory'
990
+ }}
991
+ >
992
+ {customCards ??
993
+ (designerRows ?? products).map((product) => {
994
+ const card = (
995
+ <NextLink
996
+ key={product.key}
997
+ // NextLink warns when href resolves to undefined, so a card whose
998
+ // product url the collection omitted still gets a resolved value.
999
+ href={isDesigner ? '#' : product.url || '#'}
1000
+ data-product-card=""
1001
+ {...partAttrs('card')}
1002
+ // Navigating inside the designer iframe would drop the author out
1003
+ // of the page they are editing, so cards are inert there.
1004
+ onClick={(event) => {
1005
+ if (isDesigner) event.preventDefault();
1006
+ }}
1007
+ style={{
1008
+ display: 'flex',
1009
+ flexDirection: 'column',
1010
+ gap: '8px',
1011
+ color: 'inherit',
1012
+ textDecoration: 'none',
1013
+ scrollSnapAlign: 'start'
1014
+ }}
1015
+ >
1016
+ {product.image ? (
1017
+ /* Plain img: collection thumbnails come from arbitrary commerce
1018
+ CDNs that the brand next.config may not allow. */
1019
+ /* eslint-disable-next-line @next/next/no-img-element */
1020
+ <img
1021
+ src={product.image}
1022
+ alt={product.name}
1023
+ loading="lazy"
1024
+ data-product-card-image=""
1025
+ {...partAttrs('card-image')}
1026
+ style={{
1027
+ width: '100%',
1028
+ height: 'auto',
1029
+ aspectRatio: imageRatio || undefined,
1030
+ display: 'block',
1031
+ objectFit: 'cover'
1032
+ }}
1033
+ />
1034
+ ) : (
1035
+ <span
1036
+ data-product-card-image=""
1037
+ {...partAttrs('card-image')}
1038
+ style={{
1039
+ display: 'block',
1040
+ width: '100%',
1041
+ aspectRatio: imageRatio || '3 / 4',
1042
+ background: '#f1f5f9'
1043
+ }}
1044
+ />
1045
+ )}
1046
+
1047
+ {showName && (
1048
+ <span data-product-card-name="" {...partAttrs('card-name')}>
1049
+ {product.name}
1050
+ </span>
1051
+ )}
1052
+
1053
+ {showPrice && product.price && (
1054
+ <span data-product-card-price="" {...partAttrs('card-price')}>
1055
+ {product.price}
1056
+ </span>
1057
+ )}
1058
+ </NextLink>
1059
+ );
1060
+
1061
+ // Untouched unless the author asked for a trigger: a published
1062
+ // strip has never had a wrapper around its cards, and the card is
1063
+ // an anchor, which may not contain a button.
1064
+ if (!showQuickView) return card;
1065
+
1066
+ // Storefront-side a card whose payload carried no usable id cannot
1067
+ // be opened, so no dead trigger is drawn over it. The designer
1068
+ // keeps it, so placeholder rows stay stylable.
1069
+ const canQuickView = isDesigner || product.pk !== undefined;
1070
+ const isThisCardOpen = quickViewCard?.key === product.key;
1071
+
1072
+ return (
1073
+ <div
1074
+ key={product.key}
1075
+ data-product-card-slot=""
1076
+ {...partAttrs('card-slot', { open: isThisCardOpen })}
1077
+ style={{ position: 'relative', scrollSnapAlign: 'start' }}
1078
+ >
1079
+ {card}
1080
+ {canQuickView && (
1081
+ <button
1082
+ type="button"
1083
+ data-product-card-quick-view=""
1084
+ {...partAttrs('quick-view-trigger', {
1085
+ open: isThisCardOpen
1086
+ })}
1087
+ aria-haspopup="dialog"
1088
+ aria-expanded={isThisCardOpen}
1089
+ onClick={(event) =>
1090
+ openQuickView(product, event.currentTarget)
1091
+ }
1092
+ style={{
1093
+ position: 'absolute',
1094
+ left: '50%',
1095
+ bottom: '8px',
1096
+ transform: 'translateX(-50%)',
1097
+ display: 'inline-flex',
1098
+ alignItems: 'center',
1099
+ gap: '6px',
1100
+ padding: '6px 12px',
1101
+ border: 'none',
1102
+ background:
1103
+ 'var(--product-recommendations-quick-view-bg, rgba(255, 255, 255, 0.92))',
1104
+ color: 'inherit',
1105
+ font: 'inherit',
1106
+ cursor: 'pointer'
1107
+ }}
1108
+ >
1109
+ <QuickViewIcon name={quickViewTriggerIcon} />
1110
+ <span>{quickViewTriggerLabel}</span>
1111
+ </button>
1112
+ )}
1113
+ </div>
1114
+ );
1115
+ })}
1116
+ </div>
1117
+
1118
+ {quickViewOpen && quickViewCard && (
1119
+ <div
1120
+ data-product-recommendations-quick-view=""
1121
+ style={{
1122
+ position: 'fixed',
1123
+ inset: 0,
1124
+ zIndex: 1000,
1125
+ display: 'flex',
1126
+ padding: '16px',
1127
+ // With the backdrop off nothing is drawn over the page, but this
1128
+ // root still covers the whole viewport — it would swallow every
1129
+ // click as an invisible sheet of glass. Letting events through it
1130
+ // (and re-enabling them on the panel) keeps the page alive.
1131
+ pointerEvents: showQuickViewBackdrop ? undefined : 'none'
1132
+ }}
1133
+ >
1134
+ {showQuickViewBackdrop && (
1135
+ <div
1136
+ data-product-card-quick-view-backdrop=""
1137
+ {...partAttrs('quick-view-backdrop', { open: true })}
1138
+ onClick={() => {
1139
+ if (quickViewCloseOnBackdrop) closeQuickView();
1140
+ }}
1141
+ style={{
1142
+ position: 'absolute',
1143
+ inset: 0,
1144
+ background: 'rgba(0, 0, 0, 0.5)'
1145
+ }}
1146
+ />
1147
+ )}
1148
+
1149
+ <div
1150
+ {...overlay.overlayProps}
1151
+ data-product-card-quick-view-panel=""
1152
+ {...partAttrs('quick-view-panel', {
1153
+ open: true,
1154
+ loading: isQuickViewLoading
1155
+ })}
1156
+ role="dialog"
1157
+ aria-label={quickViewName || quickViewTriggerLabel}
1158
+ aria-busy={isQuickViewLoading}
1159
+ // Focus target of last resort, for an overlay whose every control
1160
+ // the designer turned off.
1161
+ tabIndex={-1}
1162
+ style={{
1163
+ position: 'relative',
1164
+ // `margin: auto` inside the flex root is what centres it; the
1165
+ // backdrop is out of flow and does not compete.
1166
+ margin: 'auto',
1167
+ display: 'flex',
1168
+ flexWrap: 'wrap',
1169
+ gap: '20px',
1170
+ width: '100%',
1171
+ maxWidth: `${quickViewWidth}px`,
1172
+ maxHeight: '85vh',
1173
+ overflowY: 'auto',
1174
+ padding: '20px',
1175
+ background:
1176
+ 'var(--product-recommendations-quick-view-panel-bg, #ffffff)',
1177
+ color: 'inherit',
1178
+ textAlign: 'left',
1179
+ outline: 'none',
1180
+ boxShadow: '0 12px 32px rgba(0, 0, 0, 0.18)',
1181
+ pointerEvents: showQuickViewBackdrop ? undefined : 'auto'
1182
+ }}
1183
+ >
1184
+ {quickViewCloseVisible && (
1185
+ <button
1186
+ type="button"
1187
+ data-product-card-quick-view-close=""
1188
+ {...partAttrs('quick-view-close')}
1189
+ aria-label={quickViewCloseLabel}
1190
+ onClick={closeQuickView}
1191
+ style={{
1192
+ position: 'absolute',
1193
+ top: '12px',
1194
+ right: '12px',
1195
+ display: 'inline-flex',
1196
+ padding: '4px',
1197
+ border: 'none',
1198
+ background: 'none',
1199
+ color: 'inherit',
1200
+ cursor: 'pointer'
1201
+ }}
1202
+ >
1203
+ <CloseIcon />
1204
+ </button>
1205
+ )}
1206
+
1207
+ {quickViewImage && (
1208
+ /* Plain img, for the same reason the card uses one: commerce
1209
+ thumbnails come from CDNs the brand next.config may not list. */
1210
+ /* eslint-disable-next-line @next/next/no-img-element */
1211
+ <img
1212
+ src={quickViewImage}
1213
+ alt={quickViewName}
1214
+ data-product-card-quick-view-image=""
1215
+ {...partAttrs('quick-view-media')}
1216
+ style={{
1217
+ flex: '1 1 220px',
1218
+ minWidth: 0,
1219
+ maxWidth: '100%',
1220
+ aspectRatio: imageRatio || '3 / 4',
1221
+ objectFit: 'cover'
1222
+ }}
1223
+ />
1224
+ )}
1225
+
1226
+ <div
1227
+ data-product-card-quick-view-body=""
1228
+ {...partAttrs('quick-view-body')}
1229
+ style={{
1230
+ flex: '1 1 240px',
1231
+ minWidth: 0,
1232
+ display: 'flex',
1233
+ flexDirection: 'column',
1234
+ gap: '12px'
1235
+ }}
1236
+ >
1237
+ {quickViewName && (
1238
+ <span
1239
+ data-product-card-quick-view-name=""
1240
+ {...partAttrs('quick-view-name')}
1241
+ style={{ fontWeight: 600 }}
1242
+ >
1243
+ {quickViewName}
1244
+ </span>
1245
+ )}
1246
+
1247
+ {quickViewPrice && (
1248
+ <span
1249
+ data-product-card-quick-view-price=""
1250
+ {...partAttrs('quick-view-price')}
1251
+ >
1252
+ {quickViewPrice}
1253
+ {quickViewOldPrice && (
1254
+ <span
1255
+ data-product-card-quick-view-old-price=""
1256
+ {...partAttrs('quick-view-old-price')}
1257
+ style={{
1258
+ marginLeft: '8px',
1259
+ opacity: 0.6,
1260
+ textDecoration: 'line-through'
1261
+ }}
1262
+ >
1263
+ {quickViewOldPrice}
1264
+ </span>
1265
+ )}
1266
+ </span>
1267
+ )}
1268
+
1269
+ {quickViewStatus && (
1270
+ <span
1271
+ data-product-card-quick-view-status=""
1272
+ {...partAttrs('quick-view-status', {
1273
+ loading: isQuickViewLoading,
1274
+ invalid: isQuickViewFailed
1275
+ })}
1276
+ // The panel opens before the request settles, so the outcome
1277
+ // has to be announced rather than silently swapped in.
1278
+ aria-live="polite"
1279
+ style={{ opacity: 0.7 }}
1280
+ >
1281
+ {quickViewStatus}
1282
+ </span>
1283
+ )}
1284
+
1285
+ {isQuickViewFailed && (
1286
+ <button
1287
+ type="button"
1288
+ data-product-card-quick-view-retry=""
1289
+ {...partAttrs('quick-view-retry')}
1290
+ onClick={() => {
1291
+ // Nothing was requested in the designer, so there is
1292
+ // nothing to request again.
1293
+ if (isDesigner) return;
1294
+ refetchQuickView();
1295
+ }}
1296
+ style={{ ...quickViewButtonStyle, padding: '8px 12px' }}
1297
+ >
1298
+ {quickViewRetryLabel}
1299
+ </button>
1300
+ )}
1301
+
1302
+ {sizeOptions.length > 0 && (
1303
+ <>
1304
+ <span
1305
+ data-product-card-quick-view-size-label=""
1306
+ {...partAttrs('quick-view-size-label')}
1307
+ >
1308
+ {sizeGroupLabel}
1309
+ </span>
1310
+ <div
1311
+ data-product-card-quick-view-sizes=""
1312
+ {...partAttrs('quick-view-size-list')}
1313
+ role="group"
1314
+ aria-label={sizeGroupLabel}
1315
+ style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}
1316
+ >
1317
+ {sizeOptions.map((option, index) => {
1318
+ const value = toText(option?.value);
1319
+ const label = toText(option?.label) || value;
1320
+ const isChosen = value !== '' && value === chosenOption;
1321
+ const isSoldOut = isVariantOptionSoldOut(option);
1322
+ /**
1323
+ * A leaf with no product behind it cannot be added; in
1324
+ * the designer the sample carries none by design, so the
1325
+ * author still sees a real row of pickable sizes. A leaf
1326
+ * with no STOCK behind it cannot be added either, and
1327
+ * offering it is how a sold-out size reached the basket
1328
+ * — unless commerce explicitly says this one may still
1329
+ * be picked, which is how a shopper gets to see WHICH
1330
+ * size is gone rather than a row that simply omits it.
1331
+ */
1332
+ const isUnavailable =
1333
+ option?.is_selectable === false ||
1334
+ (isSoldOut &&
1335
+ option?.is_selectable_without_stock !== true) ||
1336
+ // A leaf the payload gave no value to cannot be named
1337
+ // by `chosenOption`, so picking it could never take
1338
+ // effect — offering it would be a control whose click
1339
+ // does nothing.
1340
+ value === '' ||
1341
+ (!isDesigner &&
1342
+ toProductPk(option?.product?.pk) === undefined);
1343
+
1344
+ return (
1345
+ <button
1346
+ type="button"
1347
+ key={value || `quick-view-size-${index}`}
1348
+ data-product-card-quick-view-size={value}
1349
+ {...partAttrs('quick-view-size', {
1350
+ selected: isChosen,
1351
+ disabled: isUnavailable,
1352
+ // Sold out is not the same fact as unpickable: the
1353
+ // one size a shopper is still allowed to select
1354
+ // and cannot buy has to be stylable as gone.
1355
+ unavailable: isSoldOut
1356
+ })}
1357
+ aria-pressed={isChosen}
1358
+ disabled={isUnavailable}
1359
+ onClick={() => setChosenOption(value)}
1360
+ style={{
1361
+ ...quickViewButtonStyle,
1362
+ padding: '6px 12px',
1363
+ cursor: isUnavailable ? 'not-allowed' : 'pointer',
1364
+ opacity: isUnavailable ? 0.45 : 1,
1365
+ fontWeight: isChosen ? 600 : 400
1366
+ }}
1367
+ >
1368
+ {label}
1369
+ </button>
1370
+ );
1371
+ })}
1372
+ </div>
1373
+ </>
1374
+ )}
1375
+
1376
+ <button
1377
+ type="button"
1378
+ data-product-card-quick-view-add=""
1379
+ data-product-card-quick-view-add-state={
1380
+ !isQuickViewReady
1381
+ ? 'pending'
1382
+ : !quickViewInStock
1383
+ ? 'out-of-stock'
1384
+ : isAdding
1385
+ ? 'busy'
1386
+ : addFeedback
1387
+ }
1388
+ {...partAttrs('quick-view-add', {
1389
+ loading: isAdding,
1390
+ disabled: quickViewAddDisabled
1391
+ })}
1392
+ disabled={quickViewAddDisabled}
1393
+ aria-busy={isAdding}
1394
+ onClick={handleQuickViewAdd}
1395
+ style={{
1396
+ padding: '12px 16px',
1397
+ border: 'none',
1398
+ background:
1399
+ 'var(--product-recommendations-quick-view-add-bg, #111827)',
1400
+ color:
1401
+ 'var(--product-recommendations-quick-view-add-color, #ffffff)',
1402
+ font: 'inherit',
1403
+ cursor: quickViewAddDisabled ? 'not-allowed' : 'pointer',
1404
+ opacity: quickViewAddDisabled ? 0.6 : 1
1405
+ }}
1406
+ >
1407
+ {quickViewAddText}
1408
+ </button>
1409
+
1410
+ {showQuickViewLink && (isDesigner || quickViewHref) && (
1411
+ <NextLink
1412
+ // Navigating inside the designer iframe would drop the author
1413
+ // out of the page they are editing, so the link is inert.
1414
+ href={isDesigner ? '#' : quickViewHref}
1415
+ data-product-card-quick-view-link=""
1416
+ {...partAttrs('quick-view-link')}
1417
+ onClick={(event) => {
1418
+ if (isDesigner) event.preventDefault();
1419
+ }}
1420
+ style={{ color: 'inherit' }}
1421
+ >
1422
+ {quickViewLinkLabel}
1423
+ </NextLink>
1424
+ )}
1425
+ </div>
1426
+ </div>
1427
+ </div>
1428
+ )}
1429
+ </div>
1430
+ );
1431
+ };
1432
+
1433
+ export default ProductRecommendationsBlock;