@akinon/pz-theme 2.0.75-beta.0 → 2.0.75

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.
@@ -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
- /** Both shapes the offers service answers with, plus a bare array. */
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
- if (Array.isArray(record.discounts)) return record.discounts as BasketOfferLike[];
68
- if (Array.isArray(record.results)) return record.results as BasketOfferLike[];
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 — the same call the brand storefronts make for
142
- * code-like balances — which is why its error copy is separate.
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-date, offer-body, offer-label, offer-value,
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 value = giftCardNumber.trim();
587
+ const number = giftCardNumber.trim();
588
+ const cvv = giftCardCvv.trim();
501
589
 
502
- if (isDesigner || !value) return;
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: value
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: couponPresentation === 'collapsible' && couponOpen
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', { open: couponOpen })}
648
- style={panelToggleStyle}
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', { open: couponOpen })}
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
- {(couponPresentation !== 'collapsible' || couponOpen) && couponBody}
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 !== undefined &&
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
- flexDirection: 'column',
722
- gap: '8px',
828
+ alignItems: 'center',
829
+ justifyContent: 'space-between',
830
+ gap: '16px',
723
831
  padding: '12px',
724
- background: 'var(--basket-summary-offer-bg, #f3efe6)'
832
+ background: 'var(--basket-summary-offer-bg, #f9f7ef)'
725
833
  }}
726
834
  >
727
- {date && <span {...partAttrs('offer-date')}>{date}</span>}
728
- {(label || value) && (
729
- <span
730
- {...partAttrs('offer-body')}
731
- style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}
732
- >
733
- {label && <span {...partAttrs('offer-label')}>{label}</span>}
734
- {value && <span {...partAttrs('offer-value')}>{value}</span>}
735
- </span>
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
- alignSelf: 'flex-start'
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', { open: giftCardOpen })}
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', { open: giftCardOpen })}
784
- style={panelToggleStyle}
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', { open: giftCardOpen })}
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
- {...partAttrs('gift-card-cvv')}
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
- // The generic is the widget's attributes bag, not the widget wrapper
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;