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

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.
@@ -2,17 +2,27 @@
2
2
 
3
3
  import React, { useState } from 'react';
4
4
  import NextLink from 'next/link';
5
+ import { useSession } from 'next-auth/react';
5
6
  import {
6
7
  basketApi,
7
8
  useGetBasketQuery,
8
9
  useApplyVoucherCodeMutation,
9
10
  useRemoveVoucherCodeMutation
10
11
  } from '@akinon/next/data/client/basket';
12
+ import { useGetBasketOffersQuery } from '@akinon/next/data/client/account';
11
13
  import { useAppDispatch } from '@akinon/next/redux/hooks';
12
14
  import { useLocalization } from '@akinon/next/hooks';
13
15
  import { getResponsiveValue, partAttrs } from '../utils';
14
16
  import { useThemePageContext } from '../theme-page-context';
15
- import { formatMoney, parseAmount } from './commerce-format';
17
+ import {
18
+ formatDate,
19
+ formatMoney,
20
+ MoneyCurrencyDisplay,
21
+ MoneyCurrencyPosition,
22
+ MoneyDecimals,
23
+ MoneyStyleOptions,
24
+ parseAmount
25
+ } from './commerce-format';
16
26
  import { hasItemTemplate, ItemTemplateList } from './item-template';
17
27
  import { readToggle } from './product-block-utils';
18
28
  import { BlockRendererProps } from './block-renderer-registry';
@@ -23,6 +33,8 @@ interface BasketTotalsLike {
23
33
  total_discount_amount?: string;
24
34
  total_quantity?: number;
25
35
  voucher_code?: string | null;
36
+ /** One entry per campaign / voucher that cut the basket. */
37
+ discounts?: Array<{ description?: string; discount?: string }>;
26
38
  basketitem_set?: Array<{ currency_type?: string }>;
27
39
  }
28
40
 
@@ -34,12 +46,79 @@ interface TotalsRow {
34
46
  type: 'subtotal' | 'discount' | 'total';
35
47
  }
36
48
 
49
+ /**
50
+ * One entry of the shopper's coupon wallet as the account offers service
51
+ * returns it (`/account/basket-offers` → `{ discounts: [...] }`).
52
+ */
53
+ interface BasketOfferLike {
54
+ pk?: number;
55
+ label?: string;
56
+ voucher_code?: string | null;
57
+ amount?: string | null;
58
+ percentage?: string | number | null;
59
+ start_datetime?: string;
60
+ end_datetime?: string;
61
+ }
62
+
63
+ /** Both shapes the offers service answers with, plus a bare array. */
64
+ const readOffers = (payload: unknown): BasketOfferLike[] => {
65
+ if (Array.isArray(payload)) return payload as BasketOfferLike[];
66
+ 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[];
69
+ return [];
70
+ };
71
+
37
72
  /**
38
73
  * Last-resort checkout target. Only used when the editor left `checkoutHref`
39
74
  * empty and no page context supplied one.
40
75
  */
41
76
  const DEFAULT_CHECKOUT_HREF = '/orders/checkout';
42
77
 
78
+ const ALLOWED_DECIMALS = ['always', 'auto', 'never'];
79
+ const ALLOWED_CURRENCY_DISPLAYS = ['', 'symbol', 'code', 'custom', 'none'];
80
+ const ALLOWED_CURRENCY_POSITIONS = ['', 'before', 'after'];
81
+ const COUPON_PRESENTATIONS = ['inline', 'collapsible'];
82
+ const DISCOUNT_ROWS = ['total', 'each'];
83
+ const COUPON_PLACEMENTS = ['before-cta', 'after-cta'];
84
+
85
+ const readOption = <T extends string>(
86
+ value: unknown,
87
+ breakpoint: string,
88
+ allowed: string[],
89
+ fallback: T
90
+ ): T => {
91
+ const raw = String(getResponsiveValue(value, breakpoint, fallback));
92
+ return (allowed.includes(raw) ? raw : fallback) as T;
93
+ };
94
+
95
+ /**
96
+ * The arrow on a collapsible panel header: the design language is a small
97
+ * right arrow that turns down while the panel is open. Drawn inline so the
98
+ * block needs no asset; the designer restyles it through the `*-toggle-icon`
99
+ * part and the `open` state.
100
+ */
101
+ const ToggleArrow = ({ open }: { open: boolean }) => (
102
+ <svg
103
+ width="8"
104
+ height="8"
105
+ viewBox="0 0 8 8"
106
+ fill="none"
107
+ aria-hidden="true"
108
+ style={{
109
+ display: 'block',
110
+ flex: '0 0 auto',
111
+ transform: open ? 'rotate(90deg)' : 'none',
112
+ transition: 'transform 150ms ease'
113
+ }}
114
+ >
115
+ <path
116
+ d="M3.4043 7.14648L6.12109 4.42871H0.0576172V3.42871H6.05078L3.4043 0.782227L4.11133 0.0751953L8 3.96387L4.11133 7.85352L3.4043 7.14648Z"
117
+ fill="currentColor"
118
+ />
119
+ </svg>
120
+ );
121
+
43
122
  /**
44
123
  * Functional commerce block: basket totals, optional voucher field and the
45
124
  * checkout call to action. Totals stay commerce-driven; the editor owns the
@@ -47,9 +126,29 @@ const DEFAULT_CHECKOUT_HREF = '/orders/checkout';
47
126
  * and the styling.
48
127
  *
49
128
  * Parts: root, totals, row, row-label, row-value, total, total-label,
50
- * total-value, coupon, coupon-applied, coupon-code, coupon-remove,
51
- * coupon-input, coupon-button, coupon-error, cta, empty. A child marked as the
52
- * item template replaces the built-in totals row.
129
+ * total-value, coupon, coupon-toggle, coupon-toggle-icon, coupon-form,
130
+ * coupon-applied, coupon-code, coupon-remove, coupon-input, coupon-button,
131
+ * coupon-error, gift-card, gift-card-toggle, gift-card-toggle-icon,
132
+ * gift-card-form, gift-card-fields, gift-card-input, gift-card-cvv,
133
+ * gift-card-button, gift-card-error, cta, cta-note, empty. A child marked as
134
+ * the item template replaces the built-in totals row.
135
+ *
136
+ * `couponPresentation: 'collapsible'` hides the voucher form behind a header
137
+ * row (`couponToggleLabel`) until the shopper opens it; `couponPlacement`
138
+ * moves the voucher (and the gift card panel) below the checkout button.
139
+ * `showGiftCard` adds a second collapsible panel with a card number and CVV.
140
+ * 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.
143
+ * `discountRows: 'each'` prints one discount row per basket campaign or
144
+ * voucher (commerce `discounts[]`, labelled with the campaign description)
145
+ * instead of the single summed row.
146
+ * `showOffers` lists the signed-in shopper's coupon wallet (the account
147
+ * basket-offers service) as cards above the panels — validity date, label,
148
+ * value and an apply control that submits the offer's voucher code; the card
149
+ * 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.
53
152
  *
54
153
  * Checkout target resolution, highest priority first:
55
154
  * 1. `checkoutHref` property — authoritative whenever the author sets it.
@@ -92,7 +191,81 @@ const BasketSummaryBlock = ({
92
191
  const discountLabel = prop('discountLabel', 'Discount');
93
192
  const totalLabel = prop('totalLabel', 'Total');
94
193
  const discountPrefix = prop('discountPrefix', '-');
194
+ // `each` prints one row per basket campaign / voucher (commerce's
195
+ // `discounts[]`, labelled with the campaign description); `total` keeps
196
+ // the single summed row.
197
+ const discountRows = readOption<'total' | 'each'>(
198
+ block.properties?.discountRows,
199
+ currentBreakpoint,
200
+ DISCOUNT_ROWS,
201
+ 'total'
202
+ );
95
203
  const emptyLabel = prop('emptyLabel', '').trim();
204
+ const shippingLabel = prop('shippingLabel', '').trim();
205
+ const shippingValue = prop('shippingValue', '').trim();
206
+ const ctaNote = prop('ctaNote', '').trim();
207
+ const couponPresentation = readOption<'inline' | 'collapsible'>(
208
+ block.properties?.couponPresentation,
209
+ currentBreakpoint,
210
+ COUPON_PRESENTATIONS,
211
+ 'inline'
212
+ );
213
+ const couponPlacement = readOption<'before-cta' | 'after-cta'>(
214
+ block.properties?.couponPlacement,
215
+ currentBreakpoint,
216
+ COUPON_PLACEMENTS,
217
+ 'before-cta'
218
+ );
219
+ const couponToggleLabel = prop('couponToggleLabel', 'Promo code');
220
+ const showGiftCard = boolProp('showGiftCard', false);
221
+ const giftCardToggleLabel = prop('giftCardToggleLabel', 'Gift card');
222
+ const giftCardNumberPlaceholder = prop(
223
+ 'giftCardNumberPlaceholder',
224
+ 'Card number'
225
+ );
226
+ const giftCardCvvPlaceholder = prop('giftCardCvvPlaceholder', 'CVV');
227
+ const giftCardApplyLabel = prop('giftCardApplyLabel', 'Apply');
228
+ const giftCardError = prop(
229
+ 'giftCardError',
230
+ 'Gift card could not be applied.'
231
+ );
232
+ const showOffers = boolProp('showOffers', false);
233
+ const offerApplyLabel = prop('offerApplyLabel', 'Apply');
234
+ const offerRemoveLabel = prop('offerRemoveLabel', 'Remove');
235
+ const offerPercentagePrefix = prop('offerPercentagePrefix', '%');
236
+ const offerApplyError = prop(
237
+ 'offerApplyError',
238
+ 'Coupon could not be applied.'
239
+ );
240
+ const rawOffersLimit = Math.round(
241
+ Number(
242
+ getResponsiveValue(block.properties?.offersLimit, currentBreakpoint, 0)
243
+ )
244
+ );
245
+ // 0 lists the whole wallet.
246
+ const offersLimit =
247
+ Number.isFinite(rawOffersLimit) && rawOffersLimit > 0 ? rawOffersLimit : 0;
248
+ const money: MoneyStyleOptions = {
249
+ decimals: readOption<MoneyDecimals>(
250
+ block.properties?.decimals,
251
+ currentBreakpoint,
252
+ ALLOWED_DECIMALS,
253
+ 'always'
254
+ ),
255
+ currencyDisplay: readOption<MoneyCurrencyDisplay>(
256
+ block.properties?.currencyDisplay,
257
+ currentBreakpoint,
258
+ ALLOWED_CURRENCY_DISPLAYS,
259
+ ''
260
+ ),
261
+ currencyText: prop('currencyText', ''),
262
+ currencyPosition: readOption<MoneyCurrencyPosition>(
263
+ block.properties?.currencyPosition,
264
+ currentBreakpoint,
265
+ ALLOWED_CURRENCY_POSITIONS,
266
+ ''
267
+ )
268
+ };
96
269
 
97
270
  const pageContext = useThemePageContext();
98
271
  const dispatch = useAppDispatch();
@@ -102,9 +275,24 @@ const BasketSummaryBlock = ({
102
275
  useApplyVoucherCodeMutation();
103
276
  const [removeVoucherCode, { isLoading: isRemoving }] =
104
277
  useRemoveVoucherCodeMutation();
278
+ const { status: sessionStatus } = useSession();
279
+ const isAuthenticated = sessionStatus === 'authenticated';
280
+ // Guests have no coupon wallet; skipping avoids a guaranteed 403. The hook
281
+ // is called unconditionally (hook order is fixed) and only paid for when
282
+ // the wallet is actually shown.
283
+ const { data: offersData } = useGetBasketOffersQuery(undefined, {
284
+ skip: !showOffers || !isAuthenticated
285
+ });
105
286
 
106
287
  const [code, setCode] = useState('');
288
+ const [offersError, setOffersError] = useState('');
289
+ const [pendingOffer, setPendingOffer] = useState('');
107
290
  const [error, setError] = useState('');
291
+ const [couponOpen, setCouponOpen] = useState(false);
292
+ const [giftCardOpen, setGiftCardOpen] = useState(false);
293
+ const [giftCardNumber, setGiftCardNumber] = useState('');
294
+ const [giftCardCvv, setGiftCardCvv] = useState('');
295
+ const [giftCardFailure, setGiftCardFailure] = useState('');
108
296
 
109
297
  const contextBasket = (pageContext?.basket ?? null) as {
110
298
  totalAmount?: string;
@@ -112,18 +300,21 @@ const BasketSummaryBlock = ({
112
300
  totalDiscountAmount?: string;
113
301
  voucherCode?: string;
114
302
  checkoutUrl?: string;
303
+ totalQuantity?: number;
115
304
  items?: Array<{ currency?: string }>;
116
305
  } | null;
117
306
 
118
307
  const liveBasket = basket as BasketTotalsLike | undefined;
119
308
 
120
309
  const totals = {
310
+ count: liveBasket?.total_quantity ?? contextBasket?.totalQuantity ?? 0,
121
311
  subtotal:
122
312
  liveBasket?.total_product_amount ?? contextBasket?.totalProductAmount,
123
313
  discount:
124
314
  liveBasket?.total_discount_amount ?? contextBasket?.totalDiscountAmount,
125
315
  total: liveBasket?.total_amount ?? contextBasket?.totalAmount,
126
- voucher: liveBasket?.voucher_code ?? contextBasket?.voucherCode ?? ''
316
+ voucher: liveBasket?.voucher_code ?? contextBasket?.voucherCode ?? '',
317
+ discounts: Array.isArray(liveBasket?.discounts) ? liveBasket!.discounts : []
127
318
  };
128
319
 
129
320
  // The basket reports the currency it was priced in; the storefront currency
@@ -134,8 +325,11 @@ const BasketSummaryBlock = ({
134
325
  localeCurrency ||
135
326
  '';
136
327
 
137
- const money = (value?: string | number) =>
138
- formatMoney(value, { locale, currency });
328
+ const formatAmount = (value?: string | number) =>
329
+ formatMoney(value, { locale, currency, ...money });
330
+ // `{count}` in a row label prints the basket's piece count ("2 Parça").
331
+ const withCount = (label: string) =>
332
+ label.replace(/\{count\}/g, String(totals.count));
139
333
 
140
334
  // No page supplies a basket page context yet, so `checkoutUrl` is inert for
141
335
  // now; the read is kept because the basket page context is being wired.
@@ -153,25 +347,54 @@ const BasketSummaryBlock = ({
153
347
  if (showSubtotal && totals.subtotal !== undefined) {
154
348
  rows.push({
155
349
  key: 'subtotal',
156
- label: subtotalLabel,
157
- value: money(totals.subtotal),
350
+ label: withCount(subtotalLabel),
351
+ value: formatAmount(totals.subtotal),
352
+ type: 'subtotal'
353
+ });
354
+ }
355
+
356
+ // A basket carries no shipping figure (that is checkout's); the row is the
357
+ // brand's own promise ("Kargo — 0 TL") and renders only when both halves are
358
+ // authored.
359
+ if (shippingLabel && shippingValue) {
360
+ rows.push({
361
+ key: 'shipping',
362
+ label: shippingLabel,
363
+ value: shippingValue,
158
364
  type: 'subtotal'
159
365
  });
160
366
  }
161
367
 
162
- if (showDiscount && (parseAmount(totals.discount) ?? 0) > 0) {
368
+ const itemisedDiscounts =
369
+ discountRows === 'each'
370
+ ? totals.discounts.filter(
371
+ (entry) => (parseAmount(entry?.discount) ?? 0) > 0
372
+ )
373
+ : [];
374
+
375
+ if (showDiscount && itemisedDiscounts.length > 0) {
376
+ itemisedDiscounts.forEach((entry, index) => {
377
+ rows.push({
378
+ key: `discount-${index}`,
379
+ label: entry.description?.trim() || discountLabel,
380
+ value: `${discountPrefix}${formatAmount(entry.discount)}`,
381
+ type: 'discount'
382
+ });
383
+ });
384
+ } else if (showDiscount && (parseAmount(totals.discount) ?? 0) > 0) {
385
+ // No itemised list (or the mode is `total`): one summed row.
163
386
  rows.push({
164
387
  key: 'discount',
165
388
  label: discountLabel,
166
- value: `${discountPrefix}${money(totals.discount)}`,
389
+ value: `${discountPrefix}${formatAmount(totals.discount)}`,
167
390
  type: 'discount'
168
391
  });
169
392
  }
170
393
 
171
394
  rows.push({
172
395
  key: 'total',
173
- label: totalLabel,
174
- value: money(totals.total),
396
+ label: withCount(totalLabel),
397
+ value: formatAmount(totals.total),
175
398
  type: 'total'
176
399
  });
177
400
 
@@ -249,6 +472,393 @@ const BasketSummaryBlock = ({
249
472
  }
250
473
  };
251
474
 
475
+ const applyOffer = async (offer: BasketOfferLike) => {
476
+ setOffersError('');
477
+ const voucher = String(offer.voucher_code ?? '').trim();
478
+
479
+ if (isDesigner || !voucher) return;
480
+
481
+ setPendingOffer(voucher);
482
+ try {
483
+ const nextBasket =
484
+ totals.voucher === voucher
485
+ ? await removeVoucherCode().unwrap()
486
+ : await applyVoucherCode({ voucher_code: voucher }).unwrap();
487
+
488
+ syncBasketCache(nextBasket);
489
+ } catch {
490
+ setOffersError(offerApplyError);
491
+ } finally {
492
+ setPendingOffer('');
493
+ }
494
+ };
495
+
496
+ const submitGiftCard = async (event: React.FormEvent) => {
497
+ event.preventDefault();
498
+ setGiftCardFailure('');
499
+
500
+ const value = giftCardNumber.trim();
501
+
502
+ if (isDesigner || !value) return;
503
+
504
+ try {
505
+ const nextBasket = await applyVoucherCode({
506
+ voucher_code: value
507
+ }).unwrap();
508
+
509
+ syncBasketCache(nextBasket);
510
+ setGiftCardNumber('');
511
+ setGiftCardCvv('');
512
+ } catch {
513
+ setGiftCardFailure(giftCardError);
514
+ }
515
+ };
516
+
517
+ const panelToggleStyle: React.CSSProperties = {
518
+ display: 'flex',
519
+ alignItems: 'center',
520
+ gap: '4px',
521
+ width: '100%',
522
+ background: 'none',
523
+ border: 'none',
524
+ color: 'inherit',
525
+ font: 'inherit',
526
+ textAlign: 'left',
527
+ cursor: 'pointer',
528
+ padding: 0
529
+ };
530
+
531
+ const fieldStyle: React.CSSProperties = {
532
+ flex: 1,
533
+ minWidth: 0,
534
+ border: '1px solid var(--basket-summary-border, #d1d5db)',
535
+ background: 'transparent',
536
+ color: 'inherit',
537
+ font: 'inherit',
538
+ padding: '8px 12px',
539
+ outline: 'none'
540
+ };
541
+
542
+ // The voucher form, plain or behind a header row. `coupon-toggle` carries
543
+ // `open` so a brand can style the expanded header (a border, a turned arrow).
544
+ const couponBody = (
545
+ <>
546
+ {totals.voucher ? (
547
+ <div
548
+ {...partAttrs('coupon-applied', { selected: true })}
549
+ style={{ display: 'flex', gap: '8px', alignItems: 'center' }}
550
+ >
551
+ <span {...partAttrs('coupon-code')}>{totals.voucher}</span>
552
+ <button
553
+ type="button"
554
+ disabled={isRemoving || isDesigner}
555
+ onClick={dropVoucher}
556
+ {...partAttrs('coupon-remove', {
557
+ disabled: isRemoving || isDesigner,
558
+ loading: isRemoving
559
+ })}
560
+ style={{
561
+ background: 'none',
562
+ border: 'none',
563
+ cursor: 'pointer',
564
+ color: 'inherit',
565
+ textDecoration: 'underline',
566
+ padding: 0
567
+ }}
568
+ >
569
+ {couponRemoveLabel}
570
+ </button>
571
+ </div>
572
+ ) : (
573
+ <form
574
+ onSubmit={submitVoucher}
575
+ {...partAttrs('coupon-form')}
576
+ style={{ display: 'flex', gap: '8px' }}
577
+ >
578
+ <input
579
+ type="text"
580
+ value={code}
581
+ onChange={(event) => setCode(event.target.value)}
582
+ placeholder={couponPlaceholder}
583
+ aria-label={couponPlaceholder}
584
+ aria-invalid={error ? true : undefined}
585
+ {...partAttrs('coupon-input', { invalid: Boolean(error) })}
586
+ style={{
587
+ flex: 1,
588
+ minWidth: 0,
589
+ border: '1px solid var(--basket-summary-border, #d1d5db)',
590
+ background: 'transparent',
591
+ color: 'inherit',
592
+ padding: '8px 12px',
593
+ outline: 'none'
594
+ }}
595
+ />
596
+ <button
597
+ type="submit"
598
+ disabled={isApplying}
599
+ {...partAttrs('coupon-button', {
600
+ disabled: isApplying,
601
+ loading: isApplying
602
+ })}
603
+ style={{
604
+ border: '1px solid currentColor',
605
+ background: 'transparent',
606
+ color: 'inherit',
607
+ cursor: 'pointer',
608
+ padding: '8px 16px'
609
+ }}
610
+ >
611
+ {couponLabel}
612
+ </button>
613
+ </form>
614
+ )}
615
+ {error && (
616
+ <div
617
+ data-basket-summary-error=""
618
+ role="alert"
619
+ {...partAttrs('coupon-error', { invalid: true })}
620
+ style={{ marginTop: '6px' }}
621
+ >
622
+ {error}
623
+ </div>
624
+ )}
625
+ </>
626
+ );
627
+
628
+ const couponPanel = showCoupon ? (
629
+ <div
630
+ data-basket-summary-coupon=""
631
+ data-basket-summary-coupon-state={
632
+ couponPresentation === 'collapsible'
633
+ ? couponOpen
634
+ ? 'open'
635
+ : 'closed'
636
+ : 'inline'
637
+ }
638
+ {...partAttrs('coupon', {
639
+ open: couponPresentation === 'collapsible' && couponOpen
640
+ })}
641
+ >
642
+ {couponPresentation === 'collapsible' && (
643
+ <button
644
+ type="button"
645
+ aria-expanded={couponOpen}
646
+ onClick={() => setCouponOpen((value) => !value)}
647
+ {...partAttrs('coupon-toggle', { open: couponOpen })}
648
+ style={panelToggleStyle}
649
+ >
650
+ <span
651
+ {...partAttrs('coupon-toggle-icon', { open: couponOpen })}
652
+ style={{ display: 'inline-flex' }}
653
+ >
654
+ <ToggleArrow open={couponOpen} />
655
+ </span>
656
+ <span>{couponToggleLabel}</span>
657
+ </button>
658
+ )}
659
+ {(couponPresentation !== 'collapsible' || couponOpen) && couponBody}
660
+ </div>
661
+ ) : null;
662
+
663
+ const walletOffers = readOffers(offersData);
664
+ const listedOffers =
665
+ offersLimit > 0 ? walletOffers.slice(0, offersLimit) : walletOffers;
666
+ // The designer gets one sample card to style when the wallet is empty or
667
+ // the author is not signed in inside the canvas.
668
+ const offers: BasketOfferLike[] =
669
+ isDesigner && listedOffers.length === 0
670
+ ? [
671
+ {
672
+ pk: 0,
673
+ label: 'Sample coupon',
674
+ amount: '100',
675
+ voucher_code: 'SAMPLE',
676
+ end_datetime: new Date().toISOString()
677
+ }
678
+ ]
679
+ : listedOffers;
680
+
681
+ const describeOffer = (offer: BasketOfferLike) => {
682
+ const percentage =
683
+ offer.percentage !== undefined &&
684
+ offer.percentage !== null &&
685
+ String(offer.percentage).trim()
686
+ ? `${offerPercentagePrefix}${offer.percentage}`
687
+ : '';
688
+ const amount =
689
+ (parseAmount(offer.amount) ?? 0) > 0 ? formatAmount(offer.amount) : '';
690
+
691
+ return {
692
+ date: formatDate(offer.end_datetime, locale),
693
+ label: offer.label || '',
694
+ value: percentage || amount
695
+ };
696
+ };
697
+
698
+ const offersPanel =
699
+ showOffers && offers.length > 0 ? (
700
+ <div
701
+ data-basket-summary-offers=""
702
+ {...partAttrs('offers')}
703
+ style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}
704
+ >
705
+ {offers.map((offer, index) => {
706
+ const voucher = String(offer.voucher_code ?? '').trim();
707
+ const applied = Boolean(voucher) && totals.voucher === voucher;
708
+ const busy =
709
+ (pendingOffer !== '' && pendingOffer === voucher) ||
710
+ ((isApplying || isRemoving) && pendingOffer === voucher);
711
+ const { date, label, value } = describeOffer(offer);
712
+
713
+ return (
714
+ <div
715
+ key={String(offer.pk ?? voucher ?? index)}
716
+ data-basket-summary-offer=""
717
+ data-basket-summary-offer-state={applied ? 'applied' : 'available'}
718
+ {...partAttrs('offer', { selected: applied })}
719
+ style={{
720
+ display: 'flex',
721
+ flexDirection: 'column',
722
+ gap: '8px',
723
+ padding: '12px',
724
+ background: 'var(--basket-summary-offer-bg, #f3efe6)'
725
+ }}
726
+ >
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
+ )}
737
+ {voucher && (
738
+ <button
739
+ type="button"
740
+ disabled={busy || isDesigner}
741
+ onClick={() => applyOffer(offer)}
742
+ {...partAttrs('offer-apply', {
743
+ selected: applied,
744
+ disabled: busy || isDesigner,
745
+ loading: busy
746
+ })}
747
+ style={{
748
+ ...panelToggleStyle,
749
+ width: 'auto',
750
+ alignSelf: 'flex-start'
751
+ }}
752
+ >
753
+ <span
754
+ {...partAttrs('offer-apply-icon', { selected: applied })}
755
+ style={{ display: 'inline-flex' }}
756
+ >
757
+ <ToggleArrow open={false} />
758
+ </span>
759
+ <span>{applied ? offerRemoveLabel : offerApplyLabel}</span>
760
+ </button>
761
+ )}
762
+ </div>
763
+ );
764
+ })}
765
+ {offersError && (
766
+ <div role="alert" {...partAttrs('offers-error', { invalid: true })}>
767
+ {offersError}
768
+ </div>
769
+ )}
770
+ </div>
771
+ ) : null;
772
+
773
+ const giftCardPanel = showGiftCard ? (
774
+ <div
775
+ data-basket-summary-gift-card=""
776
+ data-basket-summary-gift-card-state={giftCardOpen ? 'open' : 'closed'}
777
+ {...partAttrs('gift-card', { open: giftCardOpen })}
778
+ >
779
+ <button
780
+ type="button"
781
+ aria-expanded={giftCardOpen}
782
+ onClick={() => setGiftCardOpen((value) => !value)}
783
+ {...partAttrs('gift-card-toggle', { open: giftCardOpen })}
784
+ style={panelToggleStyle}
785
+ >
786
+ <span
787
+ {...partAttrs('gift-card-toggle-icon', { open: giftCardOpen })}
788
+ style={{ display: 'inline-flex' }}
789
+ >
790
+ <ToggleArrow open={giftCardOpen} />
791
+ </span>
792
+ <span>{giftCardToggleLabel}</span>
793
+ </button>
794
+ {giftCardOpen && (
795
+ <form
796
+ onSubmit={submitGiftCard}
797
+ {...partAttrs('gift-card-form')}
798
+ style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}
799
+ >
800
+ <div
801
+ {...partAttrs('gift-card-fields')}
802
+ style={{ display: 'flex', gap: '8px' }}
803
+ >
804
+ <input
805
+ type="text"
806
+ inputMode="numeric"
807
+ autoComplete="off"
808
+ value={giftCardNumber}
809
+ onChange={(event) => setGiftCardNumber(event.target.value)}
810
+ placeholder={giftCardNumberPlaceholder}
811
+ aria-label={giftCardNumberPlaceholder}
812
+ aria-invalid={giftCardFailure ? true : undefined}
813
+ {...partAttrs('gift-card-input', {
814
+ invalid: Boolean(giftCardFailure)
815
+ })}
816
+ style={fieldStyle}
817
+ />
818
+ <input
819
+ type="text"
820
+ inputMode="numeric"
821
+ autoComplete="off"
822
+ value={giftCardCvv}
823
+ onChange={(event) => setGiftCardCvv(event.target.value)}
824
+ placeholder={giftCardCvvPlaceholder}
825
+ aria-label={giftCardCvvPlaceholder}
826
+ {...partAttrs('gift-card-cvv')}
827
+ style={{ ...fieldStyle, flex: '0 0 30%' }}
828
+ />
829
+ </div>
830
+ <button
831
+ type="submit"
832
+ disabled={isApplying}
833
+ {...partAttrs('gift-card-button', {
834
+ disabled: isApplying,
835
+ loading: isApplying
836
+ })}
837
+ style={{
838
+ alignSelf: 'flex-start',
839
+ border: '1px solid currentColor',
840
+ background: 'transparent',
841
+ color: 'inherit',
842
+ font: 'inherit',
843
+ cursor: 'pointer',
844
+ padding: '8px 16px'
845
+ }}
846
+ >
847
+ {giftCardApplyLabel}
848
+ </button>
849
+ {giftCardFailure && (
850
+ <div
851
+ role="alert"
852
+ {...partAttrs('gift-card-error', { invalid: true })}
853
+ >
854
+ {giftCardFailure}
855
+ </div>
856
+ )}
857
+ </form>
858
+ )}
859
+ </div>
860
+ ) : null;
861
+
252
862
  const rowStyle: React.CSSProperties = {
253
863
  display: 'flex',
254
864
  justifyContent: 'space-between',
@@ -295,85 +905,9 @@ const BasketSummaryBlock = ({
295
905
  )}
296
906
  </div>
297
907
 
298
- {showCoupon && (
299
- <div data-basket-summary-coupon="" {...partAttrs('coupon')}>
300
- {totals.voucher ? (
301
- <div
302
- {...partAttrs('coupon-applied', { selected: true })}
303
- style={{ display: 'flex', gap: '8px', alignItems: 'center' }}
304
- >
305
- <span {...partAttrs('coupon-code')}>{totals.voucher}</span>
306
- <button
307
- type="button"
308
- disabled={isRemoving || isDesigner}
309
- onClick={dropVoucher}
310
- {...partAttrs('coupon-remove', {
311
- disabled: isRemoving || isDesigner,
312
- loading: isRemoving
313
- })}
314
- style={{
315
- background: 'none',
316
- border: 'none',
317
- cursor: 'pointer',
318
- color: 'inherit',
319
- textDecoration: 'underline',
320
- padding: 0
321
- }}
322
- >
323
- {couponRemoveLabel}
324
- </button>
325
- </div>
326
- ) : (
327
- <form onSubmit={submitVoucher} style={{ display: 'flex', gap: '8px' }}>
328
- <input
329
- type="text"
330
- value={code}
331
- onChange={(event) => setCode(event.target.value)}
332
- placeholder={couponPlaceholder}
333
- aria-label={couponPlaceholder}
334
- aria-invalid={error ? true : undefined}
335
- {...partAttrs('coupon-input', { invalid: Boolean(error) })}
336
- style={{
337
- flex: 1,
338
- minWidth: 0,
339
- border: '1px solid var(--basket-summary-border, #d1d5db)',
340
- background: 'transparent',
341
- color: 'inherit',
342
- padding: '8px 12px',
343
- outline: 'none'
344
- }}
345
- />
346
- <button
347
- type="submit"
348
- disabled={isApplying}
349
- {...partAttrs('coupon-button', {
350
- disabled: isApplying,
351
- loading: isApplying
352
- })}
353
- style={{
354
- border: '1px solid currentColor',
355
- background: 'transparent',
356
- color: 'inherit',
357
- cursor: 'pointer',
358
- padding: '8px 16px'
359
- }}
360
- >
361
- {couponLabel}
362
- </button>
363
- </form>
364
- )}
365
- {error && (
366
- <div
367
- data-basket-summary-error=""
368
- role="alert"
369
- {...partAttrs('coupon-error', { invalid: true })}
370
- style={{ marginTop: '6px' }}
371
- >
372
- {error}
373
- </div>
374
- )}
375
- </div>
376
- )}
908
+ {couponPlacement === 'before-cta' && offersPanel}
909
+ {couponPlacement === 'before-cta' && couponPanel}
910
+ {couponPlacement === 'before-cta' && giftCardPanel}
377
911
 
378
912
  {showCheckout && (
379
913
  <NextLink
@@ -397,6 +931,16 @@ const BasketSummaryBlock = ({
397
931
  {checkoutLabel}
398
932
  </NextLink>
399
933
  )}
934
+
935
+ {showCheckout && ctaNote && (
936
+ <div data-basket-summary-cta-note="" {...partAttrs('cta-note')}>
937
+ {ctaNote}
938
+ </div>
939
+ )}
940
+
941
+ {couponPlacement === 'after-cta' && offersPanel}
942
+ {couponPlacement === 'after-cta' && couponPanel}
943
+ {couponPlacement === 'after-cta' && giftCardPanel}
400
944
  </div>
401
945
  );
402
946
  };