@akinon/pz-theme 2.0.152-rc.3 → 2.0.153-beta.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.
@@ -1,8 +1,10 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useEffect, useRef, useState } from 'react';
4
+ import { useSession } from 'next-auth/react';
4
5
  import { useAddProductMutation } from '@akinon/next/data/client/product';
5
6
  import { basketApi } from '@akinon/next/data/client/basket';
7
+ import { useAddStockAlertMutation } from '@akinon/next/data/client/wishlist';
6
8
  import { useAppDispatch } from '@akinon/next/redux/hooks';
7
9
  import { getResponsiveValue, partAttrs } from '../utils';
8
10
  import {
@@ -12,6 +14,13 @@ import {
12
14
  } from '../utils/analytics';
13
15
  import { useHostModalCloser } from '../overlays/modal-host-context';
14
16
  import { useBlockAction } from '../hooks/use-block-action';
17
+ import { useLocalizeHref } from '../hooks/use-localize-href';
18
+ import { useOverlay } from '../hooks/use-overlay';
19
+ import {
20
+ CentredModalSurface,
21
+ LayeredOverlaySurface,
22
+ OverlayCloseButton
23
+ } from '../overlays/overlay-surface';
15
24
  import { BlockRendererProps } from './block-renderer-registry';
16
25
  import {
17
26
  DesignerPlaceholder,
@@ -19,10 +28,16 @@ import {
19
28
  ThemeVariantGroup,
20
29
  getVariantOptionStock,
21
30
  isProductInStock,
31
+ isOptionOfProductSelection,
32
+ isVariantOptionSoldOut,
22
33
  pickProductField,
23
34
  readToggle,
24
35
  useThemeProduct
25
36
  } from './product-block-utils';
37
+ import {
38
+ STOCK_ALERT_PICK_PARAM,
39
+ useStockAlertPick
40
+ } from './stock-alert-pick';
26
41
 
27
42
  const DEFAULT_FEEDBACK_TIMEOUT = 2000;
28
43
 
@@ -90,10 +105,26 @@ const readAvailableStock = (product: ThemeProduct | null): number | null => {
90
105
  * basket modal") runs after a SUCCESSFUL add rather than on the click: a
91
106
  * failed add, the quantity stepper and the designer canvas never run it.
92
107
  *
93
- * Parts: button, label, icon, row, quantity, quantity-button, quantity-input.
108
+ * With `stockAlert` on, a sold-out product — or a sold-out option the shopper
109
+ * picked in a variant selector set to hand its alert over here — turns the
110
+ * button into "notify me when it is back" (ZERO-5233), as long as some other
111
+ * option of it (another size of the same colour) is still in stock; with
112
+ * none left it stays on the disabled out-of-stock label. A signed-in shopper
113
+ * is registered with one press and told so in a dialog or drawer that names
114
+ * their email; a guest is sent to sign in with the page as the way back,
115
+ * because commerce only registers alerts for an account.
116
+ *
117
+ * Parts: button, label, icon, row, quantity, quantity-button, quantity-input,
118
+ * title, alert-panel, alert-description, alert-error.
94
119
  * Properties: label, addingLabel, addedLabel, errorLabel, outOfStockLabel,
95
120
  * fullWidth, quantity, showIcon, icon, iconSize, iconGap, iconPosition,
96
- * hideWhenOutOfStock,
121
+ * hideWhenOutOfStock, stockAlert, stockAlertLabel, stockAlertLoginHref,
122
+ * stockAlertReturnParam, stockAlertPresentation, stockAlertPanelSide,
123
+ * stockAlertWidth, stockAlertSheetHeight, stockAlertBackdropColor,
124
+ * stockAlertBackdropOpacity, stockAlertTitle, stockAlertMessage,
125
+ * stockAlertMessageNoEmail, stockAlertErrorMessage, stockAlertCloseIcon,
126
+ * stockAlertCloseIconSize,
127
+ * stockAlertCloseLabel,
97
128
  * feedbackDuration, showQuantitySelector, quantityMin, quantityMax,
98
129
  * quantityStep, quantityPosition, decreaseLabel, increaseLabel,
99
130
  * decreaseAriaLabel, increaseAriaLabel.
@@ -106,6 +137,13 @@ const AddToCartBlock = ({
106
137
  const product = useThemeProduct();
107
138
  const dispatch = useAppDispatch();
108
139
  const [addProduct, { isLoading }] = useAddProductMutation();
140
+ const [addStockAlert, { isLoading: isAlertSubmitting }] =
141
+ useAddStockAlertMutation();
142
+ const { data: session, status: sessionStatus } = useSession();
143
+ const localize = useLocalizeHref();
144
+ // Products this page already registered an alert for, so a second press
145
+ // reads as done instead of posting a duplicate commerce will refuse.
146
+ const [alertedPks, setAlertedPks] = useState<number[]>([]);
109
147
  const [feedback, setFeedback] = useState<'idle' | 'added' | 'error'>('idle');
110
148
  // null = the shopper has not touched the stepper, so the amount follows the
111
149
  // authored quantity even after the author changes it in the panel.
@@ -255,6 +293,115 @@ const AddToCartBlock = ({
255
293
 
256
294
  const productPk = Number(pickProductField(product, 'pk'));
257
295
  const hasProduct = Number.isFinite(productPk) && productPk > 0;
296
+ const stockAlertPick = useStockAlertPick(productPk);
297
+
298
+ // Off by default: a composition nobody edited keeps its disabled
299
+ // out-of-stock button exactly as it renders today.
300
+ const stockAlertEnabled = readToggle(
301
+ block.properties?.stockAlert,
302
+ currentBreakpoint,
303
+ false
304
+ );
305
+ const stockAlertLabel = prop('stockAlertLabel', 'Notify me when available');
306
+ const stockAlertLoginHref = prop('stockAlertLoginHref', '/auth');
307
+ // `callbackUrl` is what the brands' login views read back, and pz-theme's own
308
+ // composed login forms read it too (`auth-destination`).
309
+ const stockAlertReturnParam = prop(
310
+ 'stockAlertReturnParam',
311
+ 'callbackUrl'
312
+ ).trim();
313
+ // The same three a Panel offers, per breakpoint, so the confirmation can be
314
+ // the brand's own drawer on desktop and its bottom sheet on mobile.
315
+ const stockAlertPresentationValue = prop('stockAlertPresentation', 'modal');
316
+ const stockAlertPresentation =
317
+ stockAlertPresentationValue === 'drawer' ||
318
+ stockAlertPresentationValue === 'sheet'
319
+ ? stockAlertPresentationValue
320
+ : 'modal';
321
+ const stockAlertSheetHeightValue = Number(
322
+ getResponsiveValue(
323
+ block.properties?.stockAlertSheetHeight,
324
+ currentBreakpoint,
325
+ 60
326
+ )
327
+ );
328
+ const stockAlertSheetHeight =
329
+ Number.isFinite(stockAlertSheetHeightValue) &&
330
+ stockAlertSheetHeightValue > 0
331
+ ? Math.min(stockAlertSheetHeightValue, 100)
332
+ : 60;
333
+ const stockAlertBackdropColor = prop('stockAlertBackdropColor', '').trim();
334
+ // In percent, as the Panel's own backdrop opacity is.
335
+ const stockAlertBackdropOpacityValue = Number(
336
+ getResponsiveValue(
337
+ block.properties?.stockAlertBackdropOpacity,
338
+ currentBreakpoint,
339
+ 50
340
+ )
341
+ );
342
+ const stockAlertBackdropOpacity = Number.isFinite(
343
+ stockAlertBackdropOpacityValue
344
+ )
345
+ ? Math.min(100, Math.max(0, stockAlertBackdropOpacityValue))
346
+ : 50;
347
+ const stockAlertPanelSide =
348
+ prop('stockAlertPanelSide', 'right') === 'left' ? 'left' : 'right';
349
+ const stockAlertWidthValue = Number(
350
+ getResponsiveValue(block.properties?.stockAlertWidth, currentBreakpoint, 480)
351
+ );
352
+ const stockAlertWidth =
353
+ Number.isFinite(stockAlertWidthValue) && stockAlertWidthValue > 0
354
+ ? stockAlertWidthValue
355
+ : 480;
356
+ const stockAlertTitle = prop('stockAlertTitle', 'Notify me when available');
357
+ const stockAlertMessage = prop(
358
+ 'stockAlertMessage',
359
+ 'We will email {email} as soon as this product is back in stock.'
360
+ );
361
+ // An account opened with a phone number has no email to name.
362
+ const stockAlertMessageNoEmail = prop(
363
+ 'stockAlertMessageNoEmail',
364
+ 'We will let you know as soon as this product is back in stock.'
365
+ );
366
+ const stockAlertErrorMessage = prop(
367
+ 'stockAlertErrorMessage',
368
+ 'We could not register your alert. Please try again.'
369
+ );
370
+ const stockAlertCloseLabel = prop('stockAlertCloseLabel', 'Close');
371
+ // An icon-font class (e.g. `pz-icon-close`), so the × can be the very glyph
372
+ // the brand's own drawers close with. Only class-name characters pass: the
373
+ // value is merchant input on its way to `className`.
374
+ const stockAlertCloseIcon = prop('stockAlertCloseIcon', '')
375
+ .trim()
376
+ .replace(/[^a-zA-Z0-9_\- ]/g, '');
377
+ const stockAlertCloseIconSizeValue = Number(
378
+ getResponsiveValue(
379
+ block.properties?.stockAlertCloseIconSize,
380
+ currentBreakpoint,
381
+ 12
382
+ )
383
+ );
384
+ const stockAlertCloseIconSize =
385
+ Number.isFinite(stockAlertCloseIconSizeValue) &&
386
+ stockAlertCloseIconSizeValue > 0
387
+ ? stockAlertCloseIconSizeValue
388
+ : 12;
389
+
390
+ // What the dialog is saying, or null while it is closed.
391
+ const [alertDialog, setAlertDialog] = useState<'success' | 'error' | null>(
392
+ null
393
+ );
394
+ const alertButtonRef = useRef<HTMLButtonElement | null>(null);
395
+ const closeAlertDialog = () => setAlertDialog(null);
396
+ // Escape, the scroll lock, the focus trap and `aria-modal` all come from the
397
+ // shared primitive, as they do for every other overlay in the package.
398
+ const alertOverlay = useOverlay<HTMLDivElement>({
399
+ open: stockAlertEnabled && alertDialog !== null,
400
+ onClose: closeAlertDialog,
401
+ isDesigner,
402
+ modal: true,
403
+ returnFocusRef: alertButtonRef
404
+ });
258
405
 
259
406
  if (!hasProduct) {
260
407
  return isDesigner ? (
@@ -265,7 +412,258 @@ const AddToCartBlock = ({
265
412
  ) : null;
266
413
  }
267
414
 
415
+ const scheduleReset = () => {
416
+ if (feedbackTimer.current) clearTimeout(feedbackTimer.current);
417
+ feedbackTimer.current = setTimeout(
418
+ () => setFeedback('idle'),
419
+ feedbackDuration
420
+ );
421
+ };
422
+
268
423
  const inStock = isProductInStock(product);
424
+ const variantGroups = pickProductField<ThemeVariantGroup[]>(
425
+ product,
426
+ 'variants'
427
+ );
428
+ const optionGroups = (Array.isArray(variantGroups) ? variantGroups : []).filter(
429
+ (group) => Array.isArray(group?.options) && group.options.length > 0
430
+ );
431
+ /**
432
+ * Whether anything of the picked colour (or whatever the outer attributes
433
+ * are) can still be bought. The LAST group is the leaf one — its options are
434
+ * the sizes of the colour on the page — so "every size of this colour is
435
+ * gone" is that group having no option left in stock. Then there is nothing
436
+ * to wait for and the button says sold out (ZERO-5233).
437
+ */
438
+ const leafGroup = optionGroups[optionGroups.length - 1];
439
+ // Only the colour's OWN sizes count: commerce fills the row with other
440
+ // colours' sizes, in stock, when this colour has none.
441
+ const hasStockedOption = Boolean(
442
+ leafGroup?.options?.some(
443
+ (option) =>
444
+ !isVariantOptionSoldOut(option) &&
445
+ isOptionOfProductSelection(option, product, optionGroups)
446
+ )
447
+ );
448
+
449
+ /**
450
+ * What a "notify me" press would register: the sold-out option the shopper
451
+ * picked, otherwise the page's own product once it is sold out — but only
452
+ * while some other option of it is still in stock.
453
+ */
454
+ const alertTargetPk =
455
+ !stockAlertEnabled || !hasStockedOption
456
+ ? null
457
+ : stockAlertPick
458
+ ? stockAlertPick.productPk
459
+ : !inStock
460
+ ? productPk
461
+ : null;
462
+
463
+ if (alertTargetPk !== null) {
464
+ const alerted = alertedPks.includes(alertTargetPk);
465
+ const sessionEmail = String(
466
+ (session as { user?: { email?: string } } | null)?.user?.email || ''
467
+ );
468
+
469
+ const sendToSignIn = () => {
470
+ const href = localize(stockAlertLoginHref);
471
+ // The way back names the sold-out size too, so the shopper lands on
472
+ // "notify me" for THAT size rather than on the page's own product.
473
+ const backParams = new URLSearchParams(window.location.search);
474
+ if (stockAlertPick) {
475
+ backParams.set(STOCK_ALERT_PICK_PARAM, String(stockAlertPick.productPk));
476
+ }
477
+ const backQuery = backParams.toString();
478
+ const back = `${window.location.pathname}${
479
+ backQuery ? `?${backQuery}` : ''
480
+ }`;
481
+ const target = stockAlertReturnParam
482
+ ? `${href}${href.includes('?') ? '&' : '?'}${encodeURIComponent(
483
+ stockAlertReturnParam
484
+ )}=${encodeURIComponent(back)}`
485
+ : href;
486
+
487
+ window.location.assign(target);
488
+ };
489
+
490
+ const handleAlertClick = async (
491
+ event: React.MouseEvent<HTMLButtonElement>
492
+ ) => {
493
+ event.preventDefault();
494
+
495
+ // The designer is never a shopper: no alert, no redirect.
496
+ if (isDesigner || isAlertSubmitting) return;
497
+
498
+ // Commerce registers alerts against an account only, so a guest signs
499
+ // in first and is handed back to this very page.
500
+ if (sessionStatus !== 'authenticated') {
501
+ sendToSignIn();
502
+ return;
503
+ }
504
+
505
+ // Already on the list: say so again rather than post a duplicate.
506
+ if (alerted) {
507
+ setAlertDialog('success');
508
+ return;
509
+ }
510
+
511
+ try {
512
+ await addStockAlert({
513
+ productPk: alertTargetPk,
514
+ email: sessionEmail
515
+ }).unwrap();
516
+ setAlertedPks((pks) => [...pks, alertTargetPk]);
517
+ setAlertDialog('success');
518
+ } catch (error) {
519
+ // A session that lapsed since the page loaded is still a guest.
520
+ if ((error as { status?: number } | null)?.status === 401) {
521
+ sendToSignIn();
522
+ return;
523
+ }
524
+
525
+ // Said out loud: a shopper who believes they are on a waiting list
526
+ // and is not is worse off than one who was told nothing.
527
+ setAlertDialog('error');
528
+ }
529
+ };
530
+
531
+ const fillMessage = (template: string) =>
532
+ template.replace(/\{(email|product|option)\}/g, (match, key: string) =>
533
+ key === 'email'
534
+ ? sessionEmail
535
+ : key === 'product'
536
+ ? String(pickProductField<string>(product, 'title') || '')
537
+ : key === 'option'
538
+ ? stockAlertPick?.optionLabel || ''
539
+ : match
540
+ );
541
+
542
+ // Drawn the way a Panel draws its drawer — the title and the close are
543
+ // the panel's own first children (`title`, `close`) and the message is the
544
+ // body — so whatever a brand styled for its drawers styles this one too.
545
+ // No second close button: the × and the backdrop close it, as they do
546
+ // every other drawer.
547
+ const dialogContent = (
548
+ <div data-add-to-cart-alert="" {...partAttrs('alert-panel')}>
549
+ {alertDialog === 'error' ? (
550
+ <p {...partAttrs('alert-error')} role="alert" style={{ margin: 0 }}>
551
+ {fillMessage(stockAlertErrorMessage)}
552
+ </p>
553
+ ) : (
554
+ <p
555
+ {...partAttrs('alert-description')}
556
+ role="status"
557
+ style={{ margin: 0 }}
558
+ >
559
+ {fillMessage(
560
+ sessionEmail ? stockAlertMessage : stockAlertMessageNoEmail
561
+ )}
562
+ </p>
563
+ )}
564
+ </div>
565
+ );
566
+ const dialogHeader = (
567
+ <>
568
+ <OverlayCloseButton
569
+ label={stockAlertCloseLabel || 'Close'}
570
+ onClick={closeAlertDialog}
571
+ icon={
572
+ stockAlertCloseIcon ? (
573
+ <i
574
+ className={`flex ${stockAlertCloseIcon}`}
575
+ aria-hidden="true"
576
+ style={{ fontSize: `${stockAlertCloseIconSize}px` }}
577
+ />
578
+ ) : undefined
579
+ }
580
+ />
581
+ {stockAlertTitle && (
582
+ <div
583
+ {...partAttrs('title')}
584
+ style={{ marginBottom: '12px', fontWeight: 600 }}
585
+ >
586
+ {stockAlertTitle}
587
+ </div>
588
+ )}
589
+ </>
590
+ );
591
+ const surfaceProps = {
592
+ overlayProps: alertOverlay.overlayProps,
593
+ ariaLabel: stockAlertTitle,
594
+ closeOnOverlay: true,
595
+ onDismiss: closeAlertDialog,
596
+ width: stockAlertWidth,
597
+ backdropColor: stockAlertBackdropColor || undefined,
598
+ backdropOpacity: stockAlertBackdropOpacity,
599
+ header: dialogHeader,
600
+ markers: { root: { 'data-add-to-cart-alert-root': '' } }
601
+ };
602
+
603
+ return (
604
+ <>
605
+ <button
606
+ ref={alertButtonRef}
607
+ type="button"
608
+ data-add-to-cart=""
609
+ data-add-to-cart-state={
610
+ isAlertSubmitting
611
+ ? 'busy'
612
+ : alerted
613
+ ? 'stock-alert-sent'
614
+ : 'stock-alert'
615
+ }
616
+ {...partAttrs('button', {
617
+ unavailable: true,
618
+ loading: isAlertSubmitting,
619
+ disabled: isAlertSubmitting
620
+ })}
621
+ disabled={isAlertSubmitting}
622
+ aria-busy={isAlertSubmitting}
623
+ aria-haspopup="dialog"
624
+ onClick={handleAlertClick}
625
+ style={{
626
+ display: 'inline-flex',
627
+ alignItems: 'center',
628
+ justifyContent: 'center',
629
+ width: fullWidth ? '100%' : 'auto',
630
+ padding: 0,
631
+ margin: 0,
632
+ border: 'none',
633
+ background: 'transparent',
634
+ color: 'inherit',
635
+ font: 'inherit',
636
+ textAlign: 'center',
637
+ cursor: isAlertSubmitting ? 'default' : 'pointer',
638
+ opacity: isAlertSubmitting ? 0.7 : 1,
639
+ transition: 'opacity 150ms ease'
640
+ }}
641
+ >
642
+ <span data-add-to-cart-label="" {...partAttrs('label')}>
643
+ {stockAlertLabel}
644
+ </span>
645
+ </button>
646
+
647
+ {stockAlertEnabled &&
648
+ alertDialog !== null &&
649
+ (stockAlertPresentation === 'modal' ? (
650
+ <CentredModalSurface {...surfaceProps}>
651
+ {dialogContent}
652
+ </CentredModalSurface>
653
+ ) : (
654
+ <LayeredOverlaySurface
655
+ {...surfaceProps}
656
+ presentation={stockAlertPresentation}
657
+ showBackdrop
658
+ panelSide={stockAlertPanelSide}
659
+ sheetHeight={stockAlertSheetHeight}
660
+ >
661
+ {dialogContent}
662
+ </LayeredOverlaySurface>
663
+ ))}
664
+ </>
665
+ );
666
+ }
269
667
 
270
668
  // Brands that would rather show nothing than a dead button get the block
271
669
  // removed outright; the author still sees it in the designer.
@@ -290,14 +688,6 @@ const AddToCartBlock = ({
290
688
  ? errorLabel
291
689
  : label;
292
690
 
293
- const scheduleReset = () => {
294
- if (feedbackTimer.current) clearTimeout(feedbackTimer.current);
295
- feedbackTimer.current = setTimeout(
296
- () => setFeedback('idle'),
297
- feedbackDuration
298
- );
299
- };
300
-
301
691
  const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
302
692
  // A product card is often one big link with this button inside it. The
303
693
  // button has no default of its own (`type="button"`), so cancelling the
@@ -15,7 +15,6 @@ import {
15
15
  resolveItemProduct
16
16
  } from './item-template-utils';
17
17
  import { ThemeItemScopeProvider } from './product-block-utils';
18
- import { ThemeRowScopeProvider } from './row-scope';
19
18
 
20
19
  export {
21
20
  findItemTemplate,
@@ -121,15 +120,20 @@ export const ItemTemplateList = ({
121
120
  const scopesProducts = publishesProductItems(block.type);
122
121
  // The same slice the iterator applied, so clone N is handed the row it
123
122
  // was built from rather than row N.
124
- const slicedRows = sliceIteratorItems(block, rows).items;
123
+ const scopedRows = scopesProducts
124
+ ? sliceIteratorItems(block, rows).items
125
+ : rows;
125
126
 
126
127
  return (
127
128
  <>
128
129
  {clones.map((clone, index) => {
129
130
  const key = clone.id || `${template.id}-item-${index}`;
130
- const rowData = slicedRows[index] ?? {};
131
131
  const row = (
132
132
  <ThemeBlock
133
+ // Keyed here so the non-scoped branch is the element it always
134
+ // was: wrapping it in anything, even a fragment, would change the
135
+ // element type at this position and remount every existing row.
136
+ key={key}
133
137
  block={clone}
134
138
  placeholderId={placeholderId}
135
139
  sectionId={sectionId}
@@ -139,32 +143,22 @@ export const ItemTemplateList = ({
139
143
  currentBreakpoint={currentBreakpoint}
140
144
  />
141
145
  );
142
- const scoped = scopesProducts
143
- ? resolveItemProduct(block.type, rowData)
144
- : null;
145
146
 
146
- // Context only — the providers render no element, so the markup a
147
- // published row produces is unchanged. Every list opens the row
148
- // scope; only product lists open the product scope on top of it.
147
+ if (!scopesProducts) return row;
148
+
149
+ const scoped = resolveItemProduct(block.type, scopedRows[index]);
150
+
151
+ // Context only — the provider renders no element, so the markup a
152
+ // published row produces is unchanged.
149
153
  return (
150
- <ThemeRowScopeProvider
154
+ <ThemeItemScopeProvider
151
155
  key={key}
152
- listType={block.type}
153
- row={rowData}
154
- index={index}
156
+ product={
157
+ scoped ?? (isDesigner ? DESIGNER_SAMPLE_PRODUCT : null)
158
+ }
155
159
  >
156
- {scopesProducts ? (
157
- <ThemeItemScopeProvider
158
- product={
159
- scoped ?? (isDesigner ? DESIGNER_SAMPLE_PRODUCT : null)
160
- }
161
- >
162
- {row}
163
- </ThemeItemScopeProvider>
164
- ) : (
165
- row
166
- )}
167
- </ThemeRowScopeProvider>
160
+ {row}
161
+ </ThemeItemScopeProvider>
168
162
  );
169
163
  })}
170
164
  </>
@@ -13,14 +13,7 @@ import { readToggle } from './product-block-utils';
13
13
  import { BlockRendererProps } from './block-renderer-registry';
14
14
 
15
15
  interface OrderItemLike {
16
- id?: number;
17
- status?: { value?: string | number; label?: string };
18
- product?: {
19
- image?: string;
20
- name?: string;
21
- sku?: string;
22
- base_code?: string;
23
- } | null;
16
+ product?: { image?: string; name?: string } | null;
24
17
  }
25
18
 
26
19
  interface OrderLike {
@@ -255,23 +248,7 @@ const OrderListBlock = ({
255
248
  maxImages
256
249
  ),
257
250
  url: hrefFor(order),
258
- href: hrefFor(order),
259
- createdDate: order.created_date || '',
260
- items: (Array.isArray(order.orderitem_set) ? order.orderitem_set : []).map(
261
- (item, itemIndex) => ({
262
- id: item?.id ?? itemIndex,
263
- name: item?.product?.name || '',
264
- image: item?.product?.image || '',
265
- status: item?.status?.label || '',
266
- statusValue: String(item?.status?.value ?? ''),
267
- product: {
268
- sku: item?.product?.sku || '',
269
- base_code: item?.product?.base_code || '',
270
- name: item?.product?.name || '',
271
- image: item?.product?.image || ''
272
- }
273
- })
274
- )
251
+ href: hrefFor(order)
275
252
  }));
276
253
 
277
254
  // A designer who marked a child as the row layout gets that layout repeated
@@ -458,6 +458,46 @@ export const isVariantOptionSoldOut = (
458
458
  option?.is_selectable === false ||
459
459
  getVariantOptionStock(option) === 0;
460
460
 
461
+ /**
462
+ * Whether a LEAF option (a size) belongs to the same outer selection (the
463
+ * colour) as the product on the page (ZERO-5233).
464
+ *
465
+ * Commerce does not keep the leaf row to the page's colour. A size the colour
466
+ * does not have is filled with that size of ANOTHER colour — and on a colour
467
+ * that is sold out altogether, every size is: the yellow page lists S as the
468
+ * green one and M as the brown one, in stock. Asking "does this colour have a
469
+ * size left" of that row as sent answers yes for a colour that has none, so
470
+ * that question is asked of the colour's own sizes only.
471
+ *
472
+ * Compared on the outer groups' attribute keys: the page product carries its
473
+ * own values (`attributesKeyed`, or the raw `attributes` map) and every option
474
+ * its product's. A value missing on either side cannot be compared and counts
475
+ * as a match, so a payload without attributes behaves exactly as before.
476
+ */
477
+ export const isOptionOfProductSelection = (
478
+ option: ThemeVariantOption | null | undefined,
479
+ product: ThemeProduct | null,
480
+ groups: ThemeVariantGroup[]
481
+ ): boolean => {
482
+ const keyed = toRecord(pickProductField(product, 'attributesKeyed'));
483
+ const raw = pickProductField(product, 'attributes');
484
+ const own =
485
+ Object.keys(keyed).length > 0
486
+ ? keyed
487
+ : raw && typeof raw === 'object' && !Array.isArray(raw)
488
+ ? toRecord(raw)
489
+ : {};
490
+ const theirs = toRecord(option?.product?.attributes);
491
+
492
+ return groups.slice(0, -1).every((group) => {
493
+ const key = String(group?.attribute_key || '');
494
+ const mine = toText(own[key]);
495
+ const other = toText(theirs[key]);
496
+
497
+ return !key || !mine || !other || mine === other;
498
+ });
499
+ };
500
+
461
501
  const isColorKey = (key: string): boolean => {
462
502
  const normalized = key.toLowerCase();
463
503