@akinon/pz-theme 2.0.152-rc.2 → 2.0.152
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -52
- package/package.json +4 -4
- package/src/blocks/__tests__/add-to-cart-action.test.tsx +1 -0
- package/src/blocks/__tests__/add-to-cart-stock-alert.test.tsx +673 -0
- package/src/blocks/add-to-cart-block.tsx +400 -10
- package/src/blocks/item-template.tsx +19 -25
- package/src/blocks/order-list-block.tsx +2 -25
- package/src/blocks/product-block-utils.tsx +40 -0
- package/src/blocks/stock-alert-pick.ts +106 -0
- package/src/blocks/variant-selector-block.tsx +167 -9
- package/src/chrome/__tests__/page-context-builders.test.ts +1 -14
- package/src/chrome/page-context.ts +0 -4
- package/src/custom/README.md +0 -20
- package/src/overlays/overlay-surface.tsx +21 -12
- package/src/__tests__/row-scope.test.tsx +0 -118
- package/src/blocks/__tests__/order-list-rows.test.tsx +0 -112
- package/src/blocks/row-scope.tsx +0 -35
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
<
|
|
154
|
+
<ThemeItemScopeProvider
|
|
151
155
|
key={key}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
156
|
+
product={
|
|
157
|
+
scoped ?? (isDesigner ? DESIGNER_SAMPLE_PRODUCT : null)
|
|
158
|
+
}
|
|
155
159
|
>
|
|
156
|
-
{
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|