@akinon/pz-theme 2.0.75-rc.0 → 2.0.76-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.
- package/CHANGELOG.md +15 -32
- package/jest/__mocks__/akinon-next-plugin-module.js +19 -3
- package/package.json +4 -4
- package/src/blocks/__tests__/basket-line-layout.test.tsx +174 -5
- package/src/blocks/__tests__/basket-line-price.test.tsx +24 -0
- package/src/blocks/__tests__/basket-offers.test.tsx +96 -1
- package/src/blocks/basket-items-block.tsx +204 -14
- package/src/blocks/basket-summary-block.tsx +223 -59
- package/src/chrome/__tests__/composition-source.test.ts +125 -0
- package/src/chrome/composition-source.ts +37 -10
- package/src/chrome/retrying-widget-data.ts +33 -0
- package/src/chrome/theme-config-source.ts +6 -7
- package/src/load-placeholder-sections.ts +4 -10
- package/src/utils/__tests__/color-alpha.test.ts +29 -0
- package/src/utils/index.ts +39 -3
- package/src/utils/style-resolution.test.ts +6 -2
|
@@ -54,18 +54,39 @@ interface BasketOfferLike {
|
|
|
54
54
|
pk?: number;
|
|
55
55
|
label?: string;
|
|
56
56
|
voucher_code?: string | null;
|
|
57
|
-
amount?: string | null;
|
|
57
|
+
amount?: string | number | null;
|
|
58
58
|
percentage?: string | number | null;
|
|
59
59
|
start_datetime?: string;
|
|
60
60
|
end_datetime?: string;
|
|
61
|
+
status?: { value?: string } | string | null;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
/**
|
|
64
|
+
/** Whether an offer is live; entries without a status are taken as live. */
|
|
65
|
+
const isActiveOffer = (offer: BasketOfferLike): boolean => {
|
|
66
|
+
const status =
|
|
67
|
+
typeof offer.status === 'string' ? offer.status : offer.status?.value;
|
|
68
|
+
return !status || status === 'active';
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every shape the offers service answers with: `{ discounts: [...] }`,
|
|
73
|
+
* `{ all_discounts: [...] }` (the newer payload, where `discounts` sits empty
|
|
74
|
+
* beside it and carries only the applied ones), the paginated `{ results }`,
|
|
75
|
+
* and a bare array. The first non-empty list wins; `all_discounts` is
|
|
76
|
+
* filtered to active entries because it also lists expired ones.
|
|
77
|
+
*/
|
|
64
78
|
const readOffers = (payload: unknown): BasketOfferLike[] => {
|
|
65
79
|
if (Array.isArray(payload)) return payload as BasketOfferLike[];
|
|
66
80
|
const record = (payload ?? {}) as Record<string, unknown>;
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
const lists = [record.discounts, record.all_discounts, record.results];
|
|
82
|
+
for (const list of lists) {
|
|
83
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
84
|
+
const offers = list as BasketOfferLike[];
|
|
85
|
+
return list === record.all_discounts
|
|
86
|
+
? offers.filter(isActiveOffer)
|
|
87
|
+
: offers;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
69
90
|
return [];
|
|
70
91
|
};
|
|
71
92
|
|
|
@@ -82,6 +103,20 @@ const COUPON_PRESENTATIONS = ['inline', 'collapsible'];
|
|
|
82
103
|
const DISCOUNT_ROWS = ['total', 'each'];
|
|
83
104
|
const COUPON_PLACEMENTS = ['before-cta', 'after-cta'];
|
|
84
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The message commerce put on a rejected voucher ("Kupon kodu geçerli
|
|
108
|
+
* değildir."), so the shopper sees the real reason instead of the generic
|
|
109
|
+
* authored copy. The authored copy stays the fallback for network errors and
|
|
110
|
+
* payloads without one.
|
|
111
|
+
*/
|
|
112
|
+
const readCommerceError = (error: unknown): string => {
|
|
113
|
+
const data = (error as { data?: Record<string, unknown> })?.data;
|
|
114
|
+
if (!data) return '';
|
|
115
|
+
const raw = data.non_field_errors ?? data.detail ?? data.error;
|
|
116
|
+
if (Array.isArray(raw)) return raw.map(String).join(' ');
|
|
117
|
+
return typeof raw === 'string' ? raw : '';
|
|
118
|
+
};
|
|
119
|
+
|
|
85
120
|
const readOption = <T extends string>(
|
|
86
121
|
value: unknown,
|
|
87
122
|
breakpoint: string,
|
|
@@ -128,6 +163,7 @@ const ToggleArrow = ({ open }: { open: boolean }) => (
|
|
|
128
163
|
* Parts: root, totals, row, row-label, row-value, total, total-label,
|
|
129
164
|
* total-value, coupon, coupon-toggle, coupon-toggle-icon, coupon-form,
|
|
130
165
|
* coupon-applied, coupon-code, coupon-remove, coupon-input, coupon-button,
|
|
166
|
+
* gift-card-applied, gift-card-code, gift-card-remove,
|
|
131
167
|
* coupon-error, gift-card, gift-card-toggle, gift-card-toggle-icon,
|
|
132
168
|
* gift-card-form, gift-card-fields, gift-card-input, gift-card-cvv,
|
|
133
169
|
* gift-card-button, gift-card-error, cta, cta-note, empty. A child marked as
|
|
@@ -138,8 +174,11 @@ const ToggleArrow = ({ open }: { open: boolean }) => (
|
|
|
138
174
|
* moves the voucher (and the gift card panel) below the checkout button.
|
|
139
175
|
* `showGiftCard` adds a second collapsible panel with a card number and CVV.
|
|
140
176
|
* Commerce exposes no basket-level gift card endpoint, so the panel redeems
|
|
141
|
-
* through the voucher endpoint
|
|
142
|
-
*
|
|
177
|
+
* through the voucher endpoint as "number<giftCardCvvSeparator>cvv" — which
|
|
178
|
+
* is why its error copy is separate. One voucher fits on a basket, so an
|
|
179
|
+
* applied promo code locks (disables) the gift card panel and an applied
|
|
180
|
+
* gift card locks the promo panel; the separator in the applied code is what
|
|
181
|
+
* tells the two apart.
|
|
143
182
|
* `discountRows: 'each'` prints one discount row per basket campaign or
|
|
144
183
|
* voucher (commerce `discounts[]`, labelled with the campaign description)
|
|
145
184
|
* instead of the single summed row.
|
|
@@ -147,8 +186,8 @@ const ToggleArrow = ({ open }: { open: boolean }) => (
|
|
|
147
186
|
* basket-offers service) as cards above the panels — validity date, label,
|
|
148
187
|
* value and an apply control that submits the offer's voucher code; the card
|
|
149
188
|
* whose code is on the basket is `selected` and its control removes it again.
|
|
150
|
-
* Parts: offers, offer, offer-
|
|
151
|
-
* offer-apply, offer-apply-icon, offers-error.
|
|
189
|
+
* Parts: offers, offer, offer-content, offer-date, offer-body, offer-label,
|
|
190
|
+
* offer-value, offer-apply, offer-apply-icon, offers-error.
|
|
152
191
|
*
|
|
153
192
|
* Checkout target resolution, highest priority first:
|
|
154
193
|
* 1. `checkoutHref` property — authoritative whenever the author sets it.
|
|
@@ -229,6 +268,16 @@ const BasketSummaryBlock = ({
|
|
|
229
268
|
'giftCardError',
|
|
230
269
|
'Gift card could not be applied.'
|
|
231
270
|
);
|
|
271
|
+
const giftCardRemoveLabel = prop('giftCardRemoveLabel', 'Remove');
|
|
272
|
+
// The commerce voucher endpoint takes one code, so the gift card panel
|
|
273
|
+
// sends "number<separator>cvv". The separator is also how the applied
|
|
274
|
+
// voucher is told apart from a promo code: a code carrying it belongs to
|
|
275
|
+
// the gift card panel, one without it to the promo panel.
|
|
276
|
+
const giftCardCvvSeparator = prop('giftCardCvvSeparator', '|');
|
|
277
|
+
// With this set (e.g. "Kupon Kodu - {code}"), the itemised discount row
|
|
278
|
+
// that belongs to the applied voucher is printed under this name instead of
|
|
279
|
+
// the campaign description commerce sends.
|
|
280
|
+
const voucherDiscountLabel = prop('voucherDiscountLabel', '');
|
|
232
281
|
const showOffers = boolProp('showOffers', false);
|
|
233
282
|
const offerApplyLabel = prop('offerApplyLabel', 'Apply');
|
|
234
283
|
const offerRemoveLabel = prop('offerRemoveLabel', 'Remove');
|
|
@@ -331,6 +380,20 @@ const BasketSummaryBlock = ({
|
|
|
331
380
|
const withCount = (label: string) =>
|
|
332
381
|
label.replace(/\{count\}/g, String(totals.count));
|
|
333
382
|
|
|
383
|
+
// One voucher fits on a basket, so an applied gift card locks the promo
|
|
384
|
+
// panel and vice versa. Which panel owns the applied code is read off the
|
|
385
|
+
// separator the gift card submit put into it.
|
|
386
|
+
const isGiftCardVoucher =
|
|
387
|
+
Boolean(totals.voucher) &&
|
|
388
|
+
Boolean(giftCardCvvSeparator) &&
|
|
389
|
+
totals.voucher.includes(giftCardCvvSeparator);
|
|
390
|
+
const couponLocked = isGiftCardVoucher;
|
|
391
|
+
const giftCardLocked = Boolean(totals.voucher) && !isGiftCardVoucher;
|
|
392
|
+
// What the applied gift card row prints: the number without its CVV.
|
|
393
|
+
const appliedGiftCardNumber = isGiftCardVoucher
|
|
394
|
+
? totals.voucher.split(giftCardCvvSeparator)[0]
|
|
395
|
+
: '';
|
|
396
|
+
|
|
334
397
|
// No page supplies a basket page context yet, so `checkoutUrl` is inert for
|
|
335
398
|
// now; the read is kept because the basket page context is being wired.
|
|
336
399
|
const checkoutHref =
|
|
@@ -372,11 +435,35 @@ const BasketSummaryBlock = ({
|
|
|
372
435
|
)
|
|
373
436
|
: [];
|
|
374
437
|
|
|
438
|
+
// The campaign names of the wallet offers whose code is the applied
|
|
439
|
+
// voucher: that is how the voucher's own row is recognised among the
|
|
440
|
+
// campaign rows, since commerce describes it by campaign name only.
|
|
441
|
+
const appliedOfferLabels = readOffers(offersData)
|
|
442
|
+
.filter(
|
|
443
|
+
(offer) =>
|
|
444
|
+
Boolean(totals.voucher) &&
|
|
445
|
+
String(offer.voucher_code ?? '').trim() === totals.voucher
|
|
446
|
+
)
|
|
447
|
+
.map((offer) => (offer.label || '').trim())
|
|
448
|
+
.filter(Boolean);
|
|
449
|
+
|
|
450
|
+
const discountRowLabel = (description: string): string => {
|
|
451
|
+
if (!voucherDiscountLabel || !totals.voucher || isGiftCardVoucher) {
|
|
452
|
+
return description;
|
|
453
|
+
}
|
|
454
|
+
const ownedByVoucher =
|
|
455
|
+
appliedOfferLabels.includes(description) ||
|
|
456
|
+
description.includes(totals.voucher);
|
|
457
|
+
return ownedByVoucher
|
|
458
|
+
? voucherDiscountLabel.replace(/\{code\}/g, totals.voucher)
|
|
459
|
+
: description;
|
|
460
|
+
};
|
|
461
|
+
|
|
375
462
|
if (showDiscount && itemisedDiscounts.length > 0) {
|
|
376
463
|
itemisedDiscounts.forEach((entry, index) => {
|
|
377
464
|
rows.push({
|
|
378
465
|
key: `discount-${index}`,
|
|
379
|
-
label: entry.description?.trim() || discountLabel,
|
|
466
|
+
label: discountRowLabel(entry.description?.trim() || discountLabel),
|
|
380
467
|
value: `${discountPrefix}${formatAmount(entry.discount)}`,
|
|
381
468
|
type: 'discount'
|
|
382
469
|
});
|
|
@@ -453,8 +540,8 @@ const BasketSummaryBlock = ({
|
|
|
453
540
|
|
|
454
541
|
syncBasketCache(nextBasket);
|
|
455
542
|
setCode('');
|
|
456
|
-
} catch {
|
|
457
|
-
setError(couponApplyError);
|
|
543
|
+
} catch (applyError) {
|
|
544
|
+
setError(readCommerceError(applyError) || couponApplyError);
|
|
458
545
|
}
|
|
459
546
|
};
|
|
460
547
|
|
|
@@ -486,8 +573,8 @@ const BasketSummaryBlock = ({
|
|
|
486
573
|
: await applyVoucherCode({ voucher_code: voucher }).unwrap();
|
|
487
574
|
|
|
488
575
|
syncBasketCache(nextBasket);
|
|
489
|
-
} catch {
|
|
490
|
-
setOffersError(offerApplyError);
|
|
576
|
+
} catch (applyError) {
|
|
577
|
+
setOffersError(readCommerceError(applyError) || offerApplyError);
|
|
491
578
|
} finally {
|
|
492
579
|
setPendingOffer('');
|
|
493
580
|
}
|
|
@@ -497,23 +584,31 @@ const BasketSummaryBlock = ({
|
|
|
497
584
|
event.preventDefault();
|
|
498
585
|
setGiftCardFailure('');
|
|
499
586
|
|
|
500
|
-
const
|
|
587
|
+
const number = giftCardNumber.trim();
|
|
588
|
+
const cvv = giftCardCvv.trim();
|
|
501
589
|
|
|
502
|
-
if (isDesigner || !
|
|
590
|
+
if (isDesigner || !number) return;
|
|
591
|
+
// The design pairs the number with a CVV; an empty one is the same
|
|
592
|
+
// "invalid card" answer commerce would give, without the round trip.
|
|
593
|
+
if (!cvv) {
|
|
594
|
+
setGiftCardFailure(giftCardError);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
503
597
|
|
|
504
598
|
try {
|
|
505
599
|
const nextBasket = await applyVoucherCode({
|
|
506
|
-
voucher_code:
|
|
600
|
+
voucher_code: `${number}${giftCardCvvSeparator}${cvv}`
|
|
507
601
|
}).unwrap();
|
|
508
602
|
|
|
509
603
|
syncBasketCache(nextBasket);
|
|
510
604
|
setGiftCardNumber('');
|
|
511
605
|
setGiftCardCvv('');
|
|
512
|
-
} catch {
|
|
513
|
-
setGiftCardFailure(giftCardError);
|
|
606
|
+
} catch (applyError) {
|
|
607
|
+
setGiftCardFailure(readCommerceError(applyError) || giftCardError);
|
|
514
608
|
}
|
|
515
609
|
};
|
|
516
610
|
|
|
611
|
+
|
|
517
612
|
const panelToggleStyle: React.CSSProperties = {
|
|
518
613
|
display: 'flex',
|
|
519
614
|
alignItems: 'center',
|
|
@@ -543,7 +638,7 @@ const BasketSummaryBlock = ({
|
|
|
543
638
|
// `open` so a brand can style the expanded header (a border, a turned arrow).
|
|
544
639
|
const couponBody = (
|
|
545
640
|
<>
|
|
546
|
-
{totals.voucher ? (
|
|
641
|
+
{totals.voucher && !isGiftCardVoucher ? (
|
|
547
642
|
<div
|
|
548
643
|
{...partAttrs('coupon-applied', { selected: true })}
|
|
549
644
|
style={{ display: 'flex', gap: '8px', alignItems: 'center' }}
|
|
@@ -636,27 +731,41 @@ const BasketSummaryBlock = ({
|
|
|
636
731
|
: 'inline'
|
|
637
732
|
}
|
|
638
733
|
{...partAttrs('coupon', {
|
|
639
|
-
open:
|
|
734
|
+
open:
|
|
735
|
+
couponPresentation === 'collapsible' && couponOpen && !couponLocked,
|
|
736
|
+
disabled: couponLocked
|
|
640
737
|
})}
|
|
641
738
|
>
|
|
642
739
|
{couponPresentation === 'collapsible' && (
|
|
643
740
|
<button
|
|
644
741
|
type="button"
|
|
645
|
-
aria-expanded={couponOpen}
|
|
742
|
+
aria-expanded={couponOpen && !couponLocked}
|
|
743
|
+
aria-disabled={couponLocked || undefined}
|
|
744
|
+
disabled={couponLocked}
|
|
646
745
|
onClick={() => setCouponOpen((value) => !value)}
|
|
647
|
-
{...partAttrs('coupon-toggle', {
|
|
648
|
-
|
|
746
|
+
{...partAttrs('coupon-toggle', {
|
|
747
|
+
open: couponOpen && !couponLocked,
|
|
748
|
+
disabled: couponLocked
|
|
749
|
+
})}
|
|
750
|
+
style={{
|
|
751
|
+
...panelToggleStyle,
|
|
752
|
+
cursor: couponLocked ? 'default' : 'pointer'
|
|
753
|
+
}}
|
|
649
754
|
>
|
|
650
755
|
<span
|
|
651
|
-
{...partAttrs('coupon-toggle-icon', {
|
|
756
|
+
{...partAttrs('coupon-toggle-icon', {
|
|
757
|
+
open: couponOpen && !couponLocked
|
|
758
|
+
})}
|
|
652
759
|
style={{ display: 'inline-flex' }}
|
|
653
760
|
>
|
|
654
|
-
<ToggleArrow open={couponOpen} />
|
|
761
|
+
<ToggleArrow open={couponOpen && !couponLocked} />
|
|
655
762
|
</span>
|
|
656
763
|
<span>{couponToggleLabel}</span>
|
|
657
764
|
</button>
|
|
658
765
|
)}
|
|
659
|
-
{
|
|
766
|
+
{!couponLocked &&
|
|
767
|
+
(couponPresentation !== 'collapsible' || couponOpen) &&
|
|
768
|
+
couponBody}
|
|
660
769
|
</div>
|
|
661
770
|
) : null;
|
|
662
771
|
|
|
@@ -680,9 +789,7 @@ const BasketSummaryBlock = ({
|
|
|
680
789
|
|
|
681
790
|
const describeOffer = (offer: BasketOfferLike) => {
|
|
682
791
|
const percentage =
|
|
683
|
-
offer.percentage
|
|
684
|
-
offer.percentage !== null &&
|
|
685
|
-
String(offer.percentage).trim()
|
|
792
|
+
(parseAmount(offer.percentage) ?? 0) > 0
|
|
686
793
|
? `${offerPercentagePrefix}${offer.percentage}`
|
|
687
794
|
: '';
|
|
688
795
|
const amount =
|
|
@@ -718,22 +825,36 @@ const BasketSummaryBlock = ({
|
|
|
718
825
|
{...partAttrs('offer', { selected: applied })}
|
|
719
826
|
style={{
|
|
720
827
|
display: 'flex',
|
|
721
|
-
|
|
722
|
-
|
|
828
|
+
alignItems: 'center',
|
|
829
|
+
justifyContent: 'space-between',
|
|
830
|
+
gap: '16px',
|
|
723
831
|
padding: '12px',
|
|
724
|
-
background: 'var(--basket-summary-offer-bg, #
|
|
832
|
+
background: 'var(--basket-summary-offer-bg, #f9f7ef)'
|
|
725
833
|
}}
|
|
726
834
|
>
|
|
727
|
-
{
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
835
|
+
{/* Date chip over the name/value row; the apply control sits
|
|
836
|
+
beside them, vertically centred — the wallet card layout. */}
|
|
837
|
+
<div
|
|
838
|
+
{...partAttrs('offer-content')}
|
|
839
|
+
style={{
|
|
840
|
+
display: 'flex',
|
|
841
|
+
flexDirection: 'column',
|
|
842
|
+
alignItems: 'flex-start',
|
|
843
|
+
gap: '8px',
|
|
844
|
+
minWidth: 0
|
|
845
|
+
}}
|
|
846
|
+
>
|
|
847
|
+
{date && <span {...partAttrs('offer-date')}>{date}</span>}
|
|
848
|
+
{(label || value) && (
|
|
849
|
+
<span
|
|
850
|
+
{...partAttrs('offer-body')}
|
|
851
|
+
style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}
|
|
852
|
+
>
|
|
853
|
+
{label && <span {...partAttrs('offer-label')}>{label}</span>}
|
|
854
|
+
{value && <span {...partAttrs('offer-value')}>{value}</span>}
|
|
855
|
+
</span>
|
|
856
|
+
)}
|
|
857
|
+
</div>
|
|
737
858
|
{voucher && (
|
|
738
859
|
<button
|
|
739
860
|
type="button"
|
|
@@ -747,7 +868,7 @@ const BasketSummaryBlock = ({
|
|
|
747
868
|
style={{
|
|
748
869
|
...panelToggleStyle,
|
|
749
870
|
width: 'auto',
|
|
750
|
-
|
|
871
|
+
flex: '0 0 auto'
|
|
751
872
|
}}
|
|
752
873
|
>
|
|
753
874
|
<span
|
|
@@ -774,24 +895,64 @@ const BasketSummaryBlock = ({
|
|
|
774
895
|
<div
|
|
775
896
|
data-basket-summary-gift-card=""
|
|
776
897
|
data-basket-summary-gift-card-state={giftCardOpen ? 'open' : 'closed'}
|
|
777
|
-
{...partAttrs('gift-card', {
|
|
898
|
+
{...partAttrs('gift-card', {
|
|
899
|
+
open: giftCardOpen && !giftCardLocked,
|
|
900
|
+
disabled: giftCardLocked
|
|
901
|
+
})}
|
|
778
902
|
>
|
|
779
903
|
<button
|
|
780
904
|
type="button"
|
|
781
|
-
aria-expanded={giftCardOpen}
|
|
905
|
+
aria-expanded={giftCardOpen && !giftCardLocked}
|
|
906
|
+
aria-disabled={giftCardLocked || undefined}
|
|
907
|
+
disabled={giftCardLocked}
|
|
782
908
|
onClick={() => setGiftCardOpen((value) => !value)}
|
|
783
|
-
{...partAttrs('gift-card-toggle', {
|
|
784
|
-
|
|
909
|
+
{...partAttrs('gift-card-toggle', {
|
|
910
|
+
open: giftCardOpen && !giftCardLocked,
|
|
911
|
+
disabled: giftCardLocked
|
|
912
|
+
})}
|
|
913
|
+
style={{
|
|
914
|
+
...panelToggleStyle,
|
|
915
|
+
cursor: giftCardLocked ? 'default' : 'pointer'
|
|
916
|
+
}}
|
|
785
917
|
>
|
|
786
918
|
<span
|
|
787
|
-
{...partAttrs('gift-card-toggle-icon', {
|
|
919
|
+
{...partAttrs('gift-card-toggle-icon', {
|
|
920
|
+
open: giftCardOpen && !giftCardLocked
|
|
921
|
+
})}
|
|
788
922
|
style={{ display: 'inline-flex' }}
|
|
789
923
|
>
|
|
790
|
-
<ToggleArrow open={giftCardOpen} />
|
|
924
|
+
<ToggleArrow open={giftCardOpen && !giftCardLocked} />
|
|
791
925
|
</span>
|
|
792
926
|
<span>{giftCardToggleLabel}</span>
|
|
793
927
|
</button>
|
|
794
|
-
{giftCardOpen && (
|
|
928
|
+
{isGiftCardVoucher && giftCardOpen && (
|
|
929
|
+
<div
|
|
930
|
+
{...partAttrs('gift-card-applied', { selected: true })}
|
|
931
|
+
style={{ display: 'flex', gap: '8px', alignItems: 'center' }}
|
|
932
|
+
>
|
|
933
|
+
<span {...partAttrs('gift-card-code')}>{appliedGiftCardNumber}</span>
|
|
934
|
+
<button
|
|
935
|
+
type="button"
|
|
936
|
+
disabled={isRemoving || isDesigner}
|
|
937
|
+
onClick={dropVoucher}
|
|
938
|
+
{...partAttrs('gift-card-remove', {
|
|
939
|
+
disabled: isRemoving || isDesigner,
|
|
940
|
+
loading: isRemoving
|
|
941
|
+
})}
|
|
942
|
+
style={{
|
|
943
|
+
background: 'none',
|
|
944
|
+
border: 'none',
|
|
945
|
+
cursor: 'pointer',
|
|
946
|
+
color: 'inherit',
|
|
947
|
+
textDecoration: 'underline',
|
|
948
|
+
padding: 0
|
|
949
|
+
}}
|
|
950
|
+
>
|
|
951
|
+
{giftCardRemoveLabel}
|
|
952
|
+
</button>
|
|
953
|
+
</div>
|
|
954
|
+
)}
|
|
955
|
+
{!isGiftCardVoucher && !giftCardLocked && giftCardOpen && (
|
|
795
956
|
<form
|
|
796
957
|
onSubmit={submitGiftCard}
|
|
797
958
|
{...partAttrs('gift-card-form')}
|
|
@@ -823,10 +984,21 @@ const BasketSummaryBlock = ({
|
|
|
823
984
|
onChange={(event) => setGiftCardCvv(event.target.value)}
|
|
824
985
|
placeholder={giftCardCvvPlaceholder}
|
|
825
986
|
aria-label={giftCardCvvPlaceholder}
|
|
826
|
-
{
|
|
987
|
+
aria-invalid={giftCardFailure ? true : undefined}
|
|
988
|
+
{...partAttrs('gift-card-cvv', {
|
|
989
|
+
invalid: Boolean(giftCardFailure)
|
|
990
|
+
})}
|
|
827
991
|
style={{ ...fieldStyle, flex: '0 0 30%' }}
|
|
828
992
|
/>
|
|
829
993
|
</div>
|
|
994
|
+
{giftCardFailure && (
|
|
995
|
+
<div
|
|
996
|
+
role="alert"
|
|
997
|
+
{...partAttrs('gift-card-error', { invalid: true })}
|
|
998
|
+
>
|
|
999
|
+
{giftCardFailure}
|
|
1000
|
+
</div>
|
|
1001
|
+
)}
|
|
830
1002
|
<button
|
|
831
1003
|
type="submit"
|
|
832
1004
|
disabled={isApplying}
|
|
@@ -846,14 +1018,6 @@ const BasketSummaryBlock = ({
|
|
|
846
1018
|
>
|
|
847
1019
|
{giftCardApplyLabel}
|
|
848
1020
|
</button>
|
|
849
|
-
{giftCardFailure && (
|
|
850
|
-
<div
|
|
851
|
-
role="alert"
|
|
852
|
-
{...partAttrs('gift-card-error', { invalid: true })}
|
|
853
|
-
>
|
|
854
|
-
{giftCardFailure}
|
|
855
|
-
</div>
|
|
856
|
-
)}
|
|
857
1021
|
</form>
|
|
858
1022
|
)}
|
|
859
1023
|
</div>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slot's composed/uncomposed decision and the placeholder's section
|
|
3
|
+
* loader read the SAME widget, and they used to fetch it independently: a
|
|
4
|
+
* transient failure on one side rendered the brand fallback and the
|
|
5
|
+
* composition stacked on one page, or silently dropped a published
|
|
6
|
+
* composition. These pin the shared fetch — one request, one settled result,
|
|
7
|
+
* no way to disagree.
|
|
8
|
+
*/
|
|
9
|
+
jest.mock('server-only', () => ({}), { virtual: true });
|
|
10
|
+
|
|
11
|
+
jest.mock('react', () => {
|
|
12
|
+
const actual = jest.requireActual('react');
|
|
13
|
+
// Jest runs the client build, where `cache` does not memoize. The contract
|
|
14
|
+
// under test is "both readers share one memoized fetch", so give the module
|
|
15
|
+
// the RSC behavior: same args, same settled promise.
|
|
16
|
+
const cache = <A extends unknown[], R>(fn: (...args: A) => R) => {
|
|
17
|
+
const store = new Map<string, R>();
|
|
18
|
+
return (...args: A): R => {
|
|
19
|
+
const key = JSON.stringify(args);
|
|
20
|
+
if (!store.has(key)) store.set(key, fn(...args));
|
|
21
|
+
return store.get(key) as R;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
return { ...actual, cache };
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const getWidgetData = jest.fn();
|
|
28
|
+
const getWidgetSchemaData = jest.fn();
|
|
29
|
+
const getCollectionWidgetData = jest.fn();
|
|
30
|
+
|
|
31
|
+
jest.mock('@akinon/next/data/server', () => ({
|
|
32
|
+
getWidgetData: (...args: unknown[]) => getWidgetData(...args),
|
|
33
|
+
getWidgetSchemaData: (...args: unknown[]) => getWidgetSchemaData(...args),
|
|
34
|
+
getCollectionWidgetData: (...args: unknown[]) =>
|
|
35
|
+
getCollectionWidgetData(...args)
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
jest.mock(
|
|
39
|
+
'@akinon/next/utils/app-fetch',
|
|
40
|
+
() => ({ __esModule: true, default: jest.fn() }),
|
|
41
|
+
{ virtual: true }
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
jest.mock(
|
|
45
|
+
'@akinon/next/utils/server-variables',
|
|
46
|
+
() => ({ ServerVariables: { locale: 'tr', currency: 'try' } }),
|
|
47
|
+
{ virtual: true }
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
import { getSlugCompositionState } from '../composition-source';
|
|
51
|
+
import { loadPlaceholderSections } from '../../load-placeholder-sections';
|
|
52
|
+
|
|
53
|
+
const placeholderPayload = (slug: string) => ({
|
|
54
|
+
slug,
|
|
55
|
+
attributes: {
|
|
56
|
+
theme_editor_placeholder: {
|
|
57
|
+
value: JSON.stringify([{ section_slug: 'sec-a', order: 0 }])
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
getWidgetData.mockReset();
|
|
64
|
+
getWidgetSchemaData.mockReset().mockResolvedValue(null);
|
|
65
|
+
getCollectionWidgetData.mockReset().mockResolvedValue(null);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('shared placeholder widget fetch', () => {
|
|
69
|
+
it('serves the state reader and the section loader from one request', async () => {
|
|
70
|
+
const slot = 'one-fetch-slot';
|
|
71
|
+
getWidgetData.mockImplementation(async ({ slug }: { slug: string }) =>
|
|
72
|
+
slug === slot ? placeholderPayload(slot) : null
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const state = await getSlugCompositionState(slot);
|
|
76
|
+
expect(state).toEqual({ kind: 'composed', sectionCount: 1 });
|
|
77
|
+
|
|
78
|
+
await loadPlaceholderSections(slot);
|
|
79
|
+
|
|
80
|
+
const placeholderFetches = getWidgetData.mock.calls.filter(
|
|
81
|
+
([args]) => (args as { slug: string }).slug === slot
|
|
82
|
+
);
|
|
83
|
+
expect(placeholderFetches).toHaveLength(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('recovers from a transient fetch failure for both readers at once', async () => {
|
|
87
|
+
const slot = 'flaky-slot';
|
|
88
|
+
// The old double-fetch turned exactly this flake into a double render:
|
|
89
|
+
// the state call failed (fallback shown), the loader's own call
|
|
90
|
+
// succeeded (composition shown too). Now the one shared fetch retries
|
|
91
|
+
// the flake away and both readers see the recovered widget.
|
|
92
|
+
let attempts = 0;
|
|
93
|
+
getWidgetData.mockImplementation(async ({ slug }: { slug: string }) => {
|
|
94
|
+
if (slug !== slot) return null;
|
|
95
|
+
attempts += 1;
|
|
96
|
+
if (attempts === 1) {
|
|
97
|
+
throw new Error('transient commerce hiccup');
|
|
98
|
+
}
|
|
99
|
+
return placeholderPayload(slot);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const state = await getSlugCompositionState(slot);
|
|
103
|
+
expect(state).toEqual({ kind: 'composed', sectionCount: 1 });
|
|
104
|
+
|
|
105
|
+
await loadPlaceholderSections(slot);
|
|
106
|
+
expect(attempts).toBe(2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('keeps the two readers agreeing when the widget never answers', async () => {
|
|
110
|
+
const slot = 'dead-slot';
|
|
111
|
+
let attempts = 0;
|
|
112
|
+
getWidgetData.mockImplementation(async ({ slug }: { slug: string }) => {
|
|
113
|
+
if (slug !== slot) return null;
|
|
114
|
+
attempts += 1;
|
|
115
|
+
throw new Error('commerce is down');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const state = await getSlugCompositionState(slot);
|
|
119
|
+
expect(state).toEqual({ kind: 'missing' });
|
|
120
|
+
|
|
121
|
+
const loaded = await loadPlaceholderSections(slot);
|
|
122
|
+
expect(loaded?.sections ?? []).toHaveLength(0);
|
|
123
|
+
expect(attempts).toBe(3);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -1,7 +1,42 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { getWidgetData } from '@akinon/next/data/server';
|
|
3
2
|
|
|
4
3
|
import { CompositionState, readCompositionState } from './composition-state';
|
|
4
|
+
import { getWidgetDataWithRetry } from './retrying-widget-data';
|
|
5
|
+
|
|
6
|
+
// `React.cache` exists on the RSC-capable React this package renders under;
|
|
7
|
+
// fall back to the raw function on older cores, where correctness is identical
|
|
8
|
+
// and the only cost is a second fetch.
|
|
9
|
+
const cache = (React as { cache?: <T>(fn: T) => T }).cache;
|
|
10
|
+
|
|
11
|
+
/** A placeholder widget as the data layer hands it over; `null` when absent. */
|
|
12
|
+
export type PlaceholderWidget = {
|
|
13
|
+
slug?: string;
|
|
14
|
+
attributes?:
|
|
15
|
+
| ({ theme_editor_placeholder?: unknown } & Record<string, unknown>)
|
|
16
|
+
| null;
|
|
17
|
+
} | null;
|
|
18
|
+
|
|
19
|
+
const fetchPlaceholderWidget = async (
|
|
20
|
+
slug: string
|
|
21
|
+
): Promise<PlaceholderWidget> =>
|
|
22
|
+
(await getWidgetDataWithRetry<{
|
|
23
|
+
attributes?: { theme_editor_placeholder?: unknown };
|
|
24
|
+
}>(slug)) as PlaceholderWidget;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The one fetch of a placeholder widget per request.
|
|
28
|
+
*
|
|
29
|
+
* The slot's composed/uncomposed decision and the placeholder's section
|
|
30
|
+
* loader used to fetch the same widget independently, and a transient
|
|
31
|
+
* failure on one side made them disagree: the state fetch missing while the
|
|
32
|
+
* section fetch succeeded rendered the brand fallback AND the composition
|
|
33
|
+
* stacked on one page; the reverse silently dropped a published composition.
|
|
34
|
+
* Sharing one memoized fetch makes disagreement impossible — every reader
|
|
35
|
+
* in the request sees the same settled result.
|
|
36
|
+
*/
|
|
37
|
+
export const getPlaceholderWidget = cache
|
|
38
|
+
? cache(fetchPlaceholderWidget)
|
|
39
|
+
: fetchPlaceholderWidget;
|
|
5
40
|
|
|
6
41
|
/**
|
|
7
42
|
* The one server-side answer to "is this slug composed?".
|
|
@@ -14,10 +49,7 @@ import { CompositionState, readCompositionState } from './composition-state';
|
|
|
14
49
|
*/
|
|
15
50
|
const load = async (slug: string): Promise<CompositionState> => {
|
|
16
51
|
try {
|
|
17
|
-
|
|
18
|
-
const data = await getWidgetData<{
|
|
19
|
-
theme_editor_placeholder?: unknown;
|
|
20
|
-
}>({ slug });
|
|
52
|
+
const data = await getPlaceholderWidget(slug);
|
|
21
53
|
|
|
22
54
|
return readCompositionState(data);
|
|
23
55
|
} catch {
|
|
@@ -26,9 +58,4 @@ const load = async (slug: string): Promise<CompositionState> => {
|
|
|
26
58
|
}
|
|
27
59
|
};
|
|
28
60
|
|
|
29
|
-
// `React.cache` exists on the RSC-capable React this package renders under;
|
|
30
|
-
// fall back to the raw function on older cores, where correctness is identical
|
|
31
|
-
// and the only cost is a second fetch.
|
|
32
|
-
const cache = (React as { cache?: <T>(fn: T) => T }).cache;
|
|
33
|
-
|
|
34
61
|
export const getSlugCompositionState = cache ? cache(load) : load;
|