@akinon/pz-theme 2.0.67-rc.0 → 2.0.68-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 -31
- package/jest/__mocks__/akinon-next-plugin-module.js +18 -0
- package/jest.config.js +2 -0
- package/package.json +4 -4
- package/src/blocks/__tests__/basket-line-image.test.tsx +126 -0
- package/src/blocks/__tests__/basket-line-layout.test.tsx +336 -0
- package/src/blocks/__tests__/basket-line-price.test.tsx +227 -0
- package/src/blocks/__tests__/basket-offers.test.tsx +198 -0
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +169 -0
- package/src/blocks/basket-items-block.tsx +677 -45
- package/src/blocks/basket-summary-block.tsx +636 -92
- package/src/blocks/checkout-summary-block.tsx +8 -3
- package/src/blocks/product-block-utils.tsx +31 -1
- package/src/blocks/product-recommendations-block.tsx +209 -51
- package/src/theme-section-data-context.tsx +32 -0
- package/src/theme-section.tsx +13 -0
|
@@ -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 {
|
|
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-
|
|
51
|
-
* coupon-
|
|
52
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
|
|
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}${
|
|
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:
|
|
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
|
-
{
|
|
299
|
-
|
|
300
|
-
|
|
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
|
};
|