@akinon/pz-theme 2.0.67-rc.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.
- package/CHANGELOG.md +7 -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,40 +2,235 @@
|
|
|
2
2
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import NextLink from 'next/link';
|
|
5
|
+
import { useSession } from 'next-auth/react';
|
|
5
6
|
import {
|
|
6
7
|
useGetBasketQuery,
|
|
7
8
|
useUpdateQuantityMutation
|
|
8
9
|
} from '@akinon/next/data/client/basket';
|
|
10
|
+
import {
|
|
11
|
+
useAddFavoriteMutation,
|
|
12
|
+
useGetFavoritesQuery,
|
|
13
|
+
useRemoveFavoriteMutation
|
|
14
|
+
} from '@akinon/next/data/client/wishlist';
|
|
9
15
|
import { useLocalization } from '@akinon/next/hooks';
|
|
16
|
+
import PluginModule, { Component } from '@akinon/next/components/plugin-module';
|
|
10
17
|
import { getResponsiveValue, partAttrs } from '../utils';
|
|
11
18
|
import { useThemePageContext } from '../theme-page-context';
|
|
12
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
formatMoney,
|
|
21
|
+
MoneyCurrencyDisplay,
|
|
22
|
+
MoneyCurrencyPosition,
|
|
23
|
+
MoneyDecimals,
|
|
24
|
+
MoneyStyleOptions,
|
|
25
|
+
parseAmount
|
|
26
|
+
} from './commerce-format';
|
|
13
27
|
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
14
|
-
import { readToggle } from './product-block-utils';
|
|
28
|
+
import { getLineImage, readToggle } from './product-block-utils';
|
|
15
29
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
16
30
|
|
|
17
31
|
interface BasketLineLike {
|
|
18
32
|
id?: number;
|
|
19
33
|
quantity?: number;
|
|
34
|
+
stock?: number;
|
|
20
35
|
image?: string;
|
|
21
36
|
price?: string;
|
|
37
|
+
retail_price?: string;
|
|
22
38
|
unit_price?: string;
|
|
23
39
|
total_amount?: string;
|
|
40
|
+
discount_amount?: string;
|
|
24
41
|
currency_type?: string;
|
|
25
42
|
attributes?: unknown;
|
|
43
|
+
attributes_kwargs?: unknown;
|
|
26
44
|
product?: {
|
|
27
45
|
pk?: number;
|
|
28
46
|
name?: string;
|
|
29
47
|
absolute_url?: string;
|
|
30
48
|
image?: string;
|
|
49
|
+
price?: string;
|
|
50
|
+
retail_price?: string;
|
|
51
|
+
productimage_set?: Array<{ image?: string; url?: string }>;
|
|
52
|
+
attributes?: unknown;
|
|
53
|
+
attributes_kwargs?: unknown;
|
|
31
54
|
} | null;
|
|
32
55
|
}
|
|
33
56
|
|
|
57
|
+
interface VariantRow {
|
|
58
|
+
key: string;
|
|
59
|
+
label: string;
|
|
60
|
+
value: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const FAVORITES_PAGE_SIZE = 100;
|
|
64
|
+
|
|
65
|
+
const ALLOWED_DECIMALS = ['always', 'auto', 'never'];
|
|
66
|
+
const ALLOWED_CURRENCY_DISPLAYS = ['', 'symbol', 'code', 'custom', 'none'];
|
|
67
|
+
const ALLOWED_CURRENCY_POSITIONS = ['', 'before', 'after'];
|
|
68
|
+
const VARIANT_LAYOUTS = ['inline', 'rows'];
|
|
69
|
+
const QUANTITY_CONTROLS = ['stepper', 'select'];
|
|
70
|
+
const REMOVE_PLACEMENTS = ['aside', 'inline'];
|
|
71
|
+
|
|
72
|
+
const toText = (value: unknown): string =>
|
|
73
|
+
typeof value === 'string'
|
|
74
|
+
? value.trim()
|
|
75
|
+
: typeof value === 'number' && Number.isFinite(value)
|
|
76
|
+
? String(value)
|
|
77
|
+
: '';
|
|
78
|
+
|
|
79
|
+
const humanizeKey = (key: string): string =>
|
|
80
|
+
key
|
|
81
|
+
.replace(/[_-]+/g, ' ')
|
|
82
|
+
.trim()
|
|
83
|
+
.replace(/^./, (first) => first.toUpperCase());
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The attribute value commerce sent for one key, read from the richest field
|
|
87
|
+
* first: the line's own kwargs (a chosen variant's label), then the product's
|
|
88
|
+
* kwargs, then the bare attribute maps. A line on a product that IS its own
|
|
89
|
+
* variant carries no `attributes` at all — its colour and size live only on
|
|
90
|
+
* the product — which is why the product fields are consulted too.
|
|
91
|
+
*/
|
|
92
|
+
const readAttributeValue = (line: BasketLineLike, key: string): string => {
|
|
93
|
+
const sources: unknown[] = [
|
|
94
|
+
line.attributes_kwargs,
|
|
95
|
+
line.product?.attributes_kwargs,
|
|
96
|
+
line.attributes,
|
|
97
|
+
line.product?.attributes
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
for (const source of sources) {
|
|
101
|
+
if (!source || typeof source !== 'object') continue;
|
|
102
|
+
const entry = (source as Record<string, unknown>)[key];
|
|
103
|
+
if (entry === undefined || entry === null) continue;
|
|
104
|
+
if (typeof entry === 'object') {
|
|
105
|
+
const record = entry as Record<string, unknown>;
|
|
106
|
+
const text = toText(record.label) || toText(record.value);
|
|
107
|
+
if (text) return text;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const text = toText(entry);
|
|
111
|
+
if (text) return text;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return '';
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `variantRows` is authored as "key:Label, key2:Label2"; a bare key falls back
|
|
119
|
+
* to a humanised label. Empty means "every key the line was added with".
|
|
120
|
+
*/
|
|
121
|
+
const parseVariantRowSpec = (
|
|
122
|
+
raw: string
|
|
123
|
+
): Array<{ key: string; label: string }> =>
|
|
124
|
+
raw
|
|
125
|
+
.split(',')
|
|
126
|
+
.map((entry) => entry.trim())
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.map((entry) => {
|
|
129
|
+
const separator = entry.indexOf(':');
|
|
130
|
+
const key = (separator === -1 ? entry : entry.slice(0, separator)).trim();
|
|
131
|
+
const label = separator === -1 ? '' : entry.slice(separator + 1).trim();
|
|
132
|
+
return { key, label: label || humanizeKey(key) };
|
|
133
|
+
})
|
|
134
|
+
.filter((entry) => Boolean(entry.key));
|
|
135
|
+
|
|
136
|
+
const readVariantRows = (
|
|
137
|
+
line: BasketLineLike,
|
|
138
|
+
spec: Array<{ key: string; label: string }>
|
|
139
|
+
): VariantRow[] => {
|
|
140
|
+
const entries =
|
|
141
|
+
spec.length > 0
|
|
142
|
+
? spec
|
|
143
|
+
: Object.keys(
|
|
144
|
+
line.attributes && typeof line.attributes === 'object'
|
|
145
|
+
? (line.attributes as Record<string, unknown>)
|
|
146
|
+
: {}
|
|
147
|
+
).map((key) => ({ key, label: humanizeKey(key) }));
|
|
148
|
+
|
|
149
|
+
return entries
|
|
150
|
+
.map(({ key, label }) => ({
|
|
151
|
+
key,
|
|
152
|
+
label,
|
|
153
|
+
value: readAttributeValue(line, key)
|
|
154
|
+
}))
|
|
155
|
+
.filter((row) => Boolean(row.value));
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const readOption = <T extends string>(
|
|
159
|
+
value: unknown,
|
|
160
|
+
breakpoint: string,
|
|
161
|
+
allowed: string[],
|
|
162
|
+
fallback: T
|
|
163
|
+
): T => {
|
|
164
|
+
const raw = String(getResponsiveValue(value, breakpoint, fallback));
|
|
165
|
+
return (allowed.includes(raw) ? raw : fallback) as T;
|
|
166
|
+
};
|
|
167
|
+
|
|
34
168
|
/**
|
|
35
169
|
* The basket line's variant choices as one line ("M / Red"). Commerce sends
|
|
36
170
|
* them as a plain object, so the values are joined in payload order — the keys
|
|
37
171
|
* are attribute slugs and are not shopper-facing copy.
|
|
38
172
|
*/
|
|
173
|
+
/**
|
|
174
|
+
* What a line is worth, read the way the brand storefronts read it: the
|
|
175
|
+
* struck "retail" figure comes from the price list (`retail_price` above
|
|
176
|
+
* `price`), the "in basket" figure from a basket campaign that cut the line
|
|
177
|
+
* (`discount_amount` > 0 → `total_amount / quantity`).
|
|
178
|
+
*/
|
|
179
|
+
const describeLinePrice = (item: BasketLineLike) => {
|
|
180
|
+
const price = parseAmount(item.price ?? item.product?.price);
|
|
181
|
+
const retail = parseAmount(item.retail_price ?? item.product?.retail_price);
|
|
182
|
+
const discounted =
|
|
183
|
+
price !== null && retail !== null && retail > price && price >= 0;
|
|
184
|
+
const percent =
|
|
185
|
+
discounted && retail! > 0
|
|
186
|
+
? Math.round(((retail! - price!) / retail!) * 100)
|
|
187
|
+
: 0;
|
|
188
|
+
const quantity = Number(item.quantity ?? 0);
|
|
189
|
+
const total = parseAmount(item.total_amount);
|
|
190
|
+
const basketDiscount = parseAmount(item.discount_amount) ?? 0;
|
|
191
|
+
const basketUnit =
|
|
192
|
+
basketDiscount > 0 && total !== null && quantity > 0
|
|
193
|
+
? total / quantity
|
|
194
|
+
: null;
|
|
195
|
+
|
|
196
|
+
return { price, retail, discounted, percent, basketUnit };
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// The 8×8 bookmark the basket design uses: a 1px outline (the inner cut-out
|
|
200
|
+
// is the second subpath), solid once the product is favourited.
|
|
201
|
+
const BOOKMARK_OUTLINE =
|
|
202
|
+
'M7 8L4 5.538L1 8V0H7V8ZM2 5.885L3.365 4.765L4 4.245L4.635 4.765L6 5.886V1H2V5.885Z';
|
|
203
|
+
const BOOKMARK_SOLID = 'M7 8L4 5.538L1 8V0H7V8Z';
|
|
204
|
+
|
|
205
|
+
// Sized in em so the `favorite` part's font-size sets the glyph size.
|
|
206
|
+
const FavoriteGlyph = ({ filled }: { filled: boolean }) => (
|
|
207
|
+
<svg
|
|
208
|
+
width="1em"
|
|
209
|
+
height="1em"
|
|
210
|
+
viewBox="0 0 8 8"
|
|
211
|
+
fill="currentColor"
|
|
212
|
+
fillRule="evenodd"
|
|
213
|
+
aria-hidden="true"
|
|
214
|
+
style={{ display: 'block' }}
|
|
215
|
+
>
|
|
216
|
+
<path d={filled ? BOOKMARK_SOLID : BOOKMARK_OUTLINE} />
|
|
217
|
+
</svg>
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// The 6×4 chevron next to the quantity select, from the same design.
|
|
221
|
+
const SelectChevron = () => (
|
|
222
|
+
<svg
|
|
223
|
+
width="6"
|
|
224
|
+
height="4"
|
|
225
|
+
viewBox="0 0 6 4"
|
|
226
|
+
fill="currentColor"
|
|
227
|
+
aria-hidden="true"
|
|
228
|
+
style={{ display: 'block', flex: '0 0 auto' }}
|
|
229
|
+
>
|
|
230
|
+
<path d="M0 0.882L0.847 0.071L2.994 2.238L5.153 0.071L6 0.882L2.994 3.929L0 0.882Z" />
|
|
231
|
+
</svg>
|
|
232
|
+
);
|
|
233
|
+
|
|
39
234
|
const formatVariant = (attributes: unknown, separator: string): string => {
|
|
40
235
|
if (!attributes || typeof attributes !== 'object') return '';
|
|
41
236
|
|
|
@@ -85,9 +280,30 @@ const RemoveGlyph = ({ icon }: { icon: string }) =>
|
|
|
85
280
|
* commerce-driven (basket service); the editor owns which affordances are
|
|
86
281
|
* exposed, every shopper-facing string and the styling.
|
|
87
282
|
*
|
|
88
|
-
* Parts: root, item, image, content, name, variant,
|
|
89
|
-
*
|
|
90
|
-
*
|
|
283
|
+
* Parts: root, item, media, image, favorite, content, name, variant,
|
|
284
|
+
* variant-row, variant-label, variant-value, price-row, retail-price, price
|
|
285
|
+
* (`selected` while a retail figure is struck), discount-badge,
|
|
286
|
+
* basket-price, quantity, quantity-button, quantity-input, quantity-select,
|
|
287
|
+
* quantity-select-icon, gift-wrap, actions, line-total, remove, empty. A child marked as the item
|
|
288
|
+
* template replaces the built-in row.
|
|
289
|
+
*
|
|
290
|
+
* Properties (beyond the toggles): variantLayout (inline | rows), variantRows
|
|
291
|
+
* ("key:Label, …" for the rows layout), variantLabelSuffix, quantityControl
|
|
292
|
+
* (stepper | select), quantityMax, removePlacement (aside | inline),
|
|
293
|
+
* showGiftWrap (the pz-basket-gift-pack plugin slot), and the money style
|
|
294
|
+
* options every price block shares (decimals, currencyDisplay, currencyText,
|
|
295
|
+
* currencyPosition).
|
|
296
|
+
*
|
|
297
|
+
* Price extras, all off by default so the single price line published
|
|
298
|
+
* compositions render stays untouched: showRetailPrice strikes the price-list
|
|
299
|
+
* retail figure when it sits above the price; showDiscountBadge adds the
|
|
300
|
+
* percentage cut (discountBadgeFormat, `{percent}`); showBasketDiscount adds
|
|
301
|
+
* the per-unit figure a basket campaign brought the line down to
|
|
302
|
+
* (basketDiscountFormat, `{price}`), next to the undiscounted price. With any
|
|
303
|
+
* of them on, the price moves into a `price-row` flex line. showFavorite puts
|
|
304
|
+
* a wishlist toggle in the picture's corner (guests are sent to
|
|
305
|
+
* favoriteLoginHref; favoriteAddLabel / favoriteRemoveLabel are the
|
|
306
|
+
* accessible names).
|
|
91
307
|
*/
|
|
92
308
|
const BasketItemsBlock = ({
|
|
93
309
|
block,
|
|
@@ -123,6 +339,60 @@ const BasketItemsBlock = ({
|
|
|
123
339
|
const removeLabel = prop('removeLabel', 'Remove');
|
|
124
340
|
const emptyLabel = prop('emptyLabel', '').trim();
|
|
125
341
|
const loadingLabel = prop('loadingLabel', '').trim();
|
|
342
|
+
const variantLayout = readOption<'inline' | 'rows'>(
|
|
343
|
+
block.properties?.variantLayout,
|
|
344
|
+
currentBreakpoint,
|
|
345
|
+
VARIANT_LAYOUTS,
|
|
346
|
+
'inline'
|
|
347
|
+
);
|
|
348
|
+
const variantRowSpec = parseVariantRowSpec(prop('variantRows', ''));
|
|
349
|
+
const variantLabelSuffix = prop('variantLabelSuffix', '');
|
|
350
|
+
const quantityControl = readOption<'stepper' | 'select'>(
|
|
351
|
+
block.properties?.quantityControl,
|
|
352
|
+
currentBreakpoint,
|
|
353
|
+
QUANTITY_CONTROLS,
|
|
354
|
+
'stepper'
|
|
355
|
+
);
|
|
356
|
+
const removePlacement = readOption<'aside' | 'inline'>(
|
|
357
|
+
block.properties?.removePlacement,
|
|
358
|
+
currentBreakpoint,
|
|
359
|
+
REMOVE_PLACEMENTS,
|
|
360
|
+
'aside'
|
|
361
|
+
);
|
|
362
|
+
const showGiftWrap = boolProp('showGiftWrap', false);
|
|
363
|
+
const showRetailPrice = boolProp('showRetailPrice', false);
|
|
364
|
+
const showDiscountBadge = boolProp('showDiscountBadge', false);
|
|
365
|
+
const discountBadgeFormat = prop('discountBadgeFormat', '-{percent}%');
|
|
366
|
+
const showBasketDiscount = boolProp('showBasketDiscount', false);
|
|
367
|
+
const basketDiscountFormat = prop('basketDiscountFormat', '{price}');
|
|
368
|
+
const showFavorite = boolProp('showFavorite', false);
|
|
369
|
+
const favoriteLoginHref = prop('favoriteLoginHref', '/auth');
|
|
370
|
+
const favoriteAddLabel = prop('favoriteAddLabel', 'Add to favourites');
|
|
371
|
+
const favoriteRemoveLabel = prop(
|
|
372
|
+
'favoriteRemoveLabel',
|
|
373
|
+
'Remove from favourites'
|
|
374
|
+
);
|
|
375
|
+
const money: MoneyStyleOptions = {
|
|
376
|
+
decimals: readOption<MoneyDecimals>(
|
|
377
|
+
block.properties?.decimals,
|
|
378
|
+
currentBreakpoint,
|
|
379
|
+
ALLOWED_DECIMALS,
|
|
380
|
+
'always'
|
|
381
|
+
),
|
|
382
|
+
currencyDisplay: readOption<MoneyCurrencyDisplay>(
|
|
383
|
+
block.properties?.currencyDisplay,
|
|
384
|
+
currentBreakpoint,
|
|
385
|
+
ALLOWED_CURRENCY_DISPLAYS,
|
|
386
|
+
''
|
|
387
|
+
),
|
|
388
|
+
currencyText: prop('currencyText', ''),
|
|
389
|
+
currencyPosition: readOption<MoneyCurrencyPosition>(
|
|
390
|
+
block.properties?.currencyPosition,
|
|
391
|
+
currentBreakpoint,
|
|
392
|
+
ALLOWED_CURRENCY_POSITIONS,
|
|
393
|
+
''
|
|
394
|
+
)
|
|
395
|
+
};
|
|
126
396
|
|
|
127
397
|
const imageWidth = readCount(
|
|
128
398
|
getResponsiveValue(block.properties?.imageWidth, currentBreakpoint, 72),
|
|
@@ -140,12 +410,57 @@ const BasketItemsBlock = ({
|
|
|
140
410
|
getResponsiveValue(block.properties?.maxItems, currentBreakpoint, 0),
|
|
141
411
|
0
|
|
142
412
|
);
|
|
413
|
+
const quantityMax = Math.max(
|
|
414
|
+
1,
|
|
415
|
+
readCount(
|
|
416
|
+
getResponsiveValue(block.properties?.quantityMax, currentBreakpoint, 10),
|
|
417
|
+
10
|
|
418
|
+
)
|
|
419
|
+
);
|
|
143
420
|
|
|
144
421
|
const pageContext = useThemePageContext();
|
|
145
422
|
const { locale, currency: localeCurrency } = useLocalization();
|
|
146
423
|
const { data: basket, isLoading } = useGetBasketQuery(undefined as never);
|
|
147
424
|
const [updateQuantity, { isLoading: isUpdating }] =
|
|
148
425
|
useUpdateQuantityMutation();
|
|
426
|
+
const { status: sessionStatus } = useSession();
|
|
427
|
+
const isAuthenticated = sessionStatus === 'authenticated';
|
|
428
|
+
// Favourites are user-scoped and the core has no "is favourited" endpoint,
|
|
429
|
+
// so the list is read once for the whole basket (guests and the designer
|
|
430
|
+
// skip it) and each line looks its product up in it.
|
|
431
|
+
const { data: favorites } = useGetFavoritesQuery(
|
|
432
|
+
{ limit: FAVORITES_PAGE_SIZE },
|
|
433
|
+
{ skip: !showFavorite || !isAuthenticated || isDesigner }
|
|
434
|
+
);
|
|
435
|
+
const [addFavorite, addFavoriteState] = useAddFavoriteMutation();
|
|
436
|
+
const [removeFavorite, removeFavoriteState] = useRemoveFavoriteMutation();
|
|
437
|
+
const favoriteBusy =
|
|
438
|
+
addFavoriteState.isLoading || removeFavoriteState.isLoading;
|
|
439
|
+
|
|
440
|
+
const findFavorite = (productPk: number | undefined) =>
|
|
441
|
+
productPk === undefined
|
|
442
|
+
? undefined
|
|
443
|
+
: (favorites?.results || []).find(
|
|
444
|
+
(entry) => Number(entry?.product?.pk) === productPk
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
const toggleFavorite = async (item: BasketLineLike) => {
|
|
448
|
+
const productPk = item.product?.pk;
|
|
449
|
+
|
|
450
|
+
if (isDesigner || favoriteBusy || productPk === undefined) return;
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
const entry = findFavorite(productPk);
|
|
454
|
+
|
|
455
|
+
if (entry) {
|
|
456
|
+
await removeFavorite(entry.pk).unwrap();
|
|
457
|
+
} else {
|
|
458
|
+
await addFavorite(productPk).unwrap();
|
|
459
|
+
}
|
|
460
|
+
} catch {
|
|
461
|
+
// Commerce rejected the toggle; the list query stays authoritative.
|
|
462
|
+
}
|
|
463
|
+
};
|
|
149
464
|
|
|
150
465
|
const contextBasket = (pageContext?.basket ?? null) as {
|
|
151
466
|
items?: BasketLineLike[];
|
|
@@ -183,16 +498,34 @@ const BasketItemsBlock = ({
|
|
|
183
498
|
// item.url / item.quantity / item.total / item.variant in their own row.
|
|
184
499
|
const templateItems = items.map((item, index) => {
|
|
185
500
|
const currency = item.currency_type || localeCurrency;
|
|
501
|
+
const linePrice = describeLinePrice(item);
|
|
186
502
|
|
|
187
503
|
return {
|
|
188
504
|
key: String(item.id ?? item.product?.pk ?? index),
|
|
189
505
|
name: item.product?.name || '',
|
|
190
|
-
image: item
|
|
506
|
+
image: getLineImage(item),
|
|
191
507
|
url: item.product?.absolute_url || '',
|
|
192
508
|
quantity: Number(item.quantity ?? 0),
|
|
193
509
|
variant: formatVariant(item.attributes, variantSeparator),
|
|
194
|
-
|
|
195
|
-
|
|
510
|
+
variants: readVariantRows(item, variantRowSpec),
|
|
511
|
+
price: formatMoney(item.unit_price || item.price, {
|
|
512
|
+
locale,
|
|
513
|
+
currency,
|
|
514
|
+
...money
|
|
515
|
+
}),
|
|
516
|
+
retailPrice: linePrice.discounted
|
|
517
|
+
? formatMoney(linePrice.retail ?? undefined, {
|
|
518
|
+
locale,
|
|
519
|
+
currency,
|
|
520
|
+
...money
|
|
521
|
+
})
|
|
522
|
+
: '',
|
|
523
|
+
discountPercent: linePrice.discounted ? linePrice.percent : 0,
|
|
524
|
+
basketPrice:
|
|
525
|
+
linePrice.basketUnit !== null
|
|
526
|
+
? formatMoney(linePrice.basketUnit, { locale, currency, ...money })
|
|
527
|
+
: '',
|
|
528
|
+
total: formatMoney(item.total_amount, { locale, currency, ...money }),
|
|
196
529
|
currency
|
|
197
530
|
};
|
|
198
531
|
});
|
|
@@ -264,14 +597,120 @@ const BasketItemsBlock = ({
|
|
|
264
597
|
// The line reports the currency it was priced in; the storefront
|
|
265
598
|
// currency is only the fallback when commerce omits it.
|
|
266
599
|
const currency = item.currency_type || localeCurrency;
|
|
267
|
-
const image = item
|
|
600
|
+
const image = getLineImage(item);
|
|
268
601
|
const name = item.product?.name || '';
|
|
269
602
|
const href = item.product?.absolute_url || '';
|
|
270
603
|
const linkHref = isDesigner ? '#' : href || '#';
|
|
271
604
|
const quantity = Number(item.quantity ?? 0);
|
|
272
|
-
const variant =
|
|
273
|
-
|
|
274
|
-
|
|
605
|
+
const variant =
|
|
606
|
+
showVariant && variantLayout === 'inline'
|
|
607
|
+
? formatVariant(item.attributes, variantSeparator)
|
|
608
|
+
: '';
|
|
609
|
+
const variantRows =
|
|
610
|
+
showVariant && variantLayout === 'rows'
|
|
611
|
+
? readVariantRows(item, variantRowSpec)
|
|
612
|
+
: [];
|
|
613
|
+
// A select lists every quantity the shopper can pick; the line's own
|
|
614
|
+
// quantity is always offered even when it exceeds the configured
|
|
615
|
+
// ceiling, otherwise the control would silently show a wrong value.
|
|
616
|
+
const quantityOptions = Array.from(
|
|
617
|
+
{ length: Math.max(quantityMax, quantity) },
|
|
618
|
+
(_, index) => index + 1
|
|
619
|
+
);
|
|
620
|
+
const linePrice = describeLinePrice(item);
|
|
621
|
+
const fmt = (value: number | string | null | undefined) =>
|
|
622
|
+
formatMoney(value ?? undefined, { locale, currency, ...money });
|
|
623
|
+
const priceExtras =
|
|
624
|
+
showRetailPrice || showDiscountBadge || showBasketDiscount;
|
|
625
|
+
const badgeStyle: React.CSSProperties = {
|
|
626
|
+
display: 'inline-block',
|
|
627
|
+
padding: '2px 6px',
|
|
628
|
+
background: 'var(--basket-items-badge-bg, #111827)',
|
|
629
|
+
color: 'var(--basket-items-badge-color, #ffffff)',
|
|
630
|
+
fontSize: '0.85em',
|
|
631
|
+
lineHeight: 1.4
|
|
632
|
+
};
|
|
633
|
+
const favoriteEntry = showFavorite
|
|
634
|
+
? findFavorite(item.product?.pk)
|
|
635
|
+
: undefined;
|
|
636
|
+
const isFavorite = Boolean(favoriteEntry);
|
|
637
|
+
const favoriteStyle: React.CSSProperties = {
|
|
638
|
+
position: 'absolute',
|
|
639
|
+
top: '8px',
|
|
640
|
+
right: '8px',
|
|
641
|
+
display: 'inline-flex',
|
|
642
|
+
alignItems: 'center',
|
|
643
|
+
justifyContent: 'center',
|
|
644
|
+
width: '28px',
|
|
645
|
+
height: '28px',
|
|
646
|
+
padding: 0,
|
|
647
|
+
border: 'none',
|
|
648
|
+
background: 'var(--basket-items-favorite-bg, #ffffff)',
|
|
649
|
+
color: 'inherit',
|
|
650
|
+
cursor: favoriteBusy ? 'progress' : 'pointer',
|
|
651
|
+
textDecoration: 'none'
|
|
652
|
+
};
|
|
653
|
+
// Guests get a link to the login page instead of a mutation they
|
|
654
|
+
// cannot complete; the button is inert in the designer.
|
|
655
|
+
const favoriteControl =
|
|
656
|
+
showFavorite && !isAuthenticated ? (
|
|
657
|
+
<NextLink
|
|
658
|
+
href={isDesigner ? '#' : favoriteLoginHref}
|
|
659
|
+
onClick={guard}
|
|
660
|
+
data-basket-item-favorite="guest"
|
|
661
|
+
aria-label={favoriteAddLabel}
|
|
662
|
+
{...partAttrs('favorite')}
|
|
663
|
+
style={favoriteStyle}
|
|
664
|
+
>
|
|
665
|
+
<FavoriteGlyph filled={false} />
|
|
666
|
+
</NextLink>
|
|
667
|
+
) : showFavorite ? (
|
|
668
|
+
<button
|
|
669
|
+
type="button"
|
|
670
|
+
data-basket-item-favorite={isFavorite ? 'active' : 'default'}
|
|
671
|
+
aria-pressed={isFavorite}
|
|
672
|
+
aria-label={isFavorite ? favoriteRemoveLabel : favoriteAddLabel}
|
|
673
|
+
disabled={favoriteBusy}
|
|
674
|
+
onClick={(event) => {
|
|
675
|
+
event.preventDefault();
|
|
676
|
+
event.stopPropagation();
|
|
677
|
+
toggleFavorite(item);
|
|
678
|
+
}}
|
|
679
|
+
{...partAttrs('favorite', {
|
|
680
|
+
selected: isFavorite,
|
|
681
|
+
disabled: favoriteBusy,
|
|
682
|
+
loading: favoriteBusy
|
|
683
|
+
})}
|
|
684
|
+
style={favoriteStyle}
|
|
685
|
+
>
|
|
686
|
+
<FavoriteGlyph filled={isFavorite} />
|
|
687
|
+
</button>
|
|
688
|
+
) : null;
|
|
689
|
+
|
|
690
|
+
const removeControl = allowRemove && (
|
|
691
|
+
<button
|
|
692
|
+
type="button"
|
|
693
|
+
data-basket-item-remove=""
|
|
694
|
+
aria-label={removeAriaLabel}
|
|
695
|
+
disabled={isUpdating}
|
|
696
|
+
// Quantity 0 is how the commerce basket endpoint deletes a line.
|
|
697
|
+
onClick={() => changeQuantity(item, 0)}
|
|
698
|
+
{...partAttrs('remove', { disabled: isUpdating })}
|
|
699
|
+
style={{
|
|
700
|
+
background: 'none',
|
|
701
|
+
border: 'none',
|
|
702
|
+
cursor: 'pointer',
|
|
703
|
+
color: 'inherit',
|
|
704
|
+
padding: 0
|
|
705
|
+
}}
|
|
706
|
+
>
|
|
707
|
+
{removeIcon === 'text' ? (
|
|
708
|
+
removeLabel
|
|
709
|
+
) : (
|
|
710
|
+
<RemoveGlyph icon={removeIcon} />
|
|
711
|
+
)}
|
|
712
|
+
</button>
|
|
713
|
+
);
|
|
275
714
|
|
|
276
715
|
return (
|
|
277
716
|
<li
|
|
@@ -291,7 +730,7 @@ const BasketItemsBlock = ({
|
|
|
291
730
|
: null)
|
|
292
731
|
}}
|
|
293
732
|
>
|
|
294
|
-
{showImage && image && (
|
|
733
|
+
{showImage && image && !favoriteControl && (
|
|
295
734
|
<NextLink
|
|
296
735
|
href={linkHref}
|
|
297
736
|
onClick={guard}
|
|
@@ -311,6 +750,36 @@ const BasketItemsBlock = ({
|
|
|
311
750
|
</NextLink>
|
|
312
751
|
)}
|
|
313
752
|
|
|
753
|
+
{/* With the favourite toggle on, the picture gets a positioned
|
|
754
|
+
wrapper (`media`) so the toggle can sit in its corner. */}
|
|
755
|
+
{showImage && image && favoriteControl && (
|
|
756
|
+
<div
|
|
757
|
+
{...partAttrs('media')}
|
|
758
|
+
style={{
|
|
759
|
+
flex: '0 0 auto',
|
|
760
|
+
position: 'relative',
|
|
761
|
+
display: 'block'
|
|
762
|
+
}}
|
|
763
|
+
>
|
|
764
|
+
<NextLink
|
|
765
|
+
href={linkHref}
|
|
766
|
+
onClick={guard}
|
|
767
|
+
style={{ display: 'block' }}
|
|
768
|
+
>
|
|
769
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
770
|
+
<img
|
|
771
|
+
src={image}
|
|
772
|
+
alt={name}
|
|
773
|
+
width={imageWidth}
|
|
774
|
+
height={imageHeight}
|
|
775
|
+
{...partAttrs('image')}
|
|
776
|
+
style={{ objectFit: 'cover', display: 'block' }}
|
|
777
|
+
/>
|
|
778
|
+
</NextLink>
|
|
779
|
+
{favoriteControl}
|
|
780
|
+
</div>
|
|
781
|
+
)}
|
|
782
|
+
|
|
314
783
|
<div
|
|
315
784
|
{...partAttrs('content')}
|
|
316
785
|
style={{ flex: '1 1 auto', minWidth: 0 }}
|
|
@@ -335,7 +804,41 @@ const BasketItemsBlock = ({
|
|
|
335
804
|
</div>
|
|
336
805
|
)}
|
|
337
806
|
|
|
338
|
-
{
|
|
807
|
+
{variantRows.length > 0 && (
|
|
808
|
+
<div
|
|
809
|
+
data-basket-item-variant=""
|
|
810
|
+
{...partAttrs('variant')}
|
|
811
|
+
style={{
|
|
812
|
+
marginTop: '4px',
|
|
813
|
+
display: 'flex',
|
|
814
|
+
flexDirection: 'column',
|
|
815
|
+
gap: '4px'
|
|
816
|
+
}}
|
|
817
|
+
>
|
|
818
|
+
{variantRows.map((row) => (
|
|
819
|
+
<div
|
|
820
|
+
key={row.key}
|
|
821
|
+
data-basket-item-variant-row={row.key}
|
|
822
|
+
{...partAttrs('variant-row')}
|
|
823
|
+
style={{ display: 'flex', gap: '8px' }}
|
|
824
|
+
>
|
|
825
|
+
<span {...partAttrs('variant-label')}>
|
|
826
|
+
{variantLabelSuffix
|
|
827
|
+
? `${row.label}${variantLabelSuffix}`
|
|
828
|
+
: row.label}
|
|
829
|
+
</span>
|
|
830
|
+
<span
|
|
831
|
+
{...partAttrs('variant-value')}
|
|
832
|
+
style={{ opacity: 0.8 }}
|
|
833
|
+
>
|
|
834
|
+
{row.value}
|
|
835
|
+
</span>
|
|
836
|
+
</div>
|
|
837
|
+
))}
|
|
838
|
+
</div>
|
|
839
|
+
)}
|
|
840
|
+
|
|
841
|
+
{showPrice && !priceExtras && (
|
|
339
842
|
<div
|
|
340
843
|
data-basket-item-price=""
|
|
341
844
|
{...partAttrs('price')}
|
|
@@ -343,12 +846,129 @@ const BasketItemsBlock = ({
|
|
|
343
846
|
>
|
|
344
847
|
{formatMoney(item.unit_price || item.price, {
|
|
345
848
|
locale,
|
|
346
|
-
currency
|
|
849
|
+
currency,
|
|
850
|
+
...money
|
|
347
851
|
})}
|
|
348
852
|
</div>
|
|
349
853
|
)}
|
|
350
854
|
|
|
351
|
-
{
|
|
855
|
+
{showPrice && priceExtras && (
|
|
856
|
+
<div
|
|
857
|
+
data-basket-item-price-row=""
|
|
858
|
+
{...partAttrs('price-row')}
|
|
859
|
+
style={{
|
|
860
|
+
display: 'flex',
|
|
861
|
+
flexWrap: 'wrap',
|
|
862
|
+
alignItems: 'center',
|
|
863
|
+
gap: '8px',
|
|
864
|
+
marginTop: '4px'
|
|
865
|
+
}}
|
|
866
|
+
>
|
|
867
|
+
{showRetailPrice && linePrice.discounted && (
|
|
868
|
+
<span
|
|
869
|
+
data-basket-item-retail-price=""
|
|
870
|
+
{...partAttrs('retail-price')}
|
|
871
|
+
style={{ textDecoration: 'line-through', opacity: 0.6 }}
|
|
872
|
+
>
|
|
873
|
+
{fmt(linePrice.retail)}
|
|
874
|
+
</span>
|
|
875
|
+
)}
|
|
876
|
+
<span
|
|
877
|
+
data-basket-item-price=""
|
|
878
|
+
{...partAttrs('price', {
|
|
879
|
+
selected: linePrice.discounted
|
|
880
|
+
})}
|
|
881
|
+
>
|
|
882
|
+
{showBasketDiscount && linePrice.basketUnit !== null
|
|
883
|
+
? fmt(item.price ?? item.product?.price)
|
|
884
|
+
: fmt(item.unit_price || item.price)}
|
|
885
|
+
</span>
|
|
886
|
+
{showDiscountBadge &&
|
|
887
|
+
linePrice.discounted &&
|
|
888
|
+
linePrice.percent > 0 && (
|
|
889
|
+
<span
|
|
890
|
+
data-basket-item-discount-badge=""
|
|
891
|
+
{...partAttrs('discount-badge')}
|
|
892
|
+
style={badgeStyle}
|
|
893
|
+
>
|
|
894
|
+
{discountBadgeFormat.replace(
|
|
895
|
+
/\{percent\}/g,
|
|
896
|
+
String(linePrice.percent)
|
|
897
|
+
)}
|
|
898
|
+
</span>
|
|
899
|
+
)}
|
|
900
|
+
{showBasketDiscount && linePrice.basketUnit !== null && (
|
|
901
|
+
<span
|
|
902
|
+
data-basket-item-basket-price=""
|
|
903
|
+
{...partAttrs('basket-price')}
|
|
904
|
+
style={badgeStyle}
|
|
905
|
+
>
|
|
906
|
+
{basketDiscountFormat.replace(
|
|
907
|
+
/\{price\}/g,
|
|
908
|
+
fmt(linePrice.basketUnit)
|
|
909
|
+
)}
|
|
910
|
+
</span>
|
|
911
|
+
)}
|
|
912
|
+
</div>
|
|
913
|
+
)}
|
|
914
|
+
|
|
915
|
+
{allowQuantityChange && quantityControl === 'select' && (
|
|
916
|
+
<div
|
|
917
|
+
data-basket-item-quantity=""
|
|
918
|
+
{...partAttrs('quantity', { loading: isUpdating })}
|
|
919
|
+
style={{
|
|
920
|
+
display: 'inline-flex',
|
|
921
|
+
alignItems: 'center',
|
|
922
|
+
gap: '6px',
|
|
923
|
+
marginTop: '8px',
|
|
924
|
+
padding: '2px 6px',
|
|
925
|
+
border: '1px solid var(--basket-items-border, #e5e7eb)',
|
|
926
|
+
borderRadius: '4px'
|
|
927
|
+
}}
|
|
928
|
+
>
|
|
929
|
+
{/* The native arrow is hidden so the chevron next to the
|
|
930
|
+
select is the one the brand styles. */}
|
|
931
|
+
<select
|
|
932
|
+
value={quantity}
|
|
933
|
+
aria-label={increaseAriaLabel}
|
|
934
|
+
disabled={isUpdating}
|
|
935
|
+
onChange={(event) =>
|
|
936
|
+
changeQuantity(item, Number(event.target.value))
|
|
937
|
+
}
|
|
938
|
+
{...partAttrs('quantity-select', {
|
|
939
|
+
disabled: isUpdating,
|
|
940
|
+
loading: isUpdating
|
|
941
|
+
})}
|
|
942
|
+
style={{
|
|
943
|
+
flex: '1 1 auto',
|
|
944
|
+
minWidth: 0,
|
|
945
|
+
appearance: 'none',
|
|
946
|
+
WebkitAppearance: 'none',
|
|
947
|
+
MozAppearance: 'none',
|
|
948
|
+
background: 'transparent',
|
|
949
|
+
border: 'none',
|
|
950
|
+
color: 'inherit',
|
|
951
|
+
font: 'inherit',
|
|
952
|
+
cursor: 'pointer',
|
|
953
|
+
padding: 0
|
|
954
|
+
}}
|
|
955
|
+
>
|
|
956
|
+
{quantityOptions.map((option) => (
|
|
957
|
+
<option key={option} value={option}>
|
|
958
|
+
{option}
|
|
959
|
+
</option>
|
|
960
|
+
))}
|
|
961
|
+
</select>
|
|
962
|
+
<span
|
|
963
|
+
{...partAttrs('quantity-select-icon')}
|
|
964
|
+
style={{ display: 'inline-flex', pointerEvents: 'none' }}
|
|
965
|
+
>
|
|
966
|
+
<SelectChevron />
|
|
967
|
+
</span>
|
|
968
|
+
</div>
|
|
969
|
+
)}
|
|
970
|
+
|
|
971
|
+
{allowQuantityChange && quantityControl === 'stepper' && (
|
|
352
972
|
<div
|
|
353
973
|
data-basket-item-quantity=""
|
|
354
974
|
{...partAttrs('quantity', { loading: isUpdating })}
|
|
@@ -401,6 +1021,40 @@ const BasketItemsBlock = ({
|
|
|
401
1021
|
</button>
|
|
402
1022
|
</div>
|
|
403
1023
|
)}
|
|
1024
|
+
|
|
1025
|
+
{showGiftWrap && !isDesigner && (
|
|
1026
|
+
<div
|
|
1027
|
+
data-basket-item-gift-wrap=""
|
|
1028
|
+
{...partAttrs('gift-wrap')}
|
|
1029
|
+
style={{ marginTop: '8px' }}
|
|
1030
|
+
>
|
|
1031
|
+
<PluginModule
|
|
1032
|
+
component={Component.BasketGiftPack}
|
|
1033
|
+
props={{ basketItem: item }}
|
|
1034
|
+
/>
|
|
1035
|
+
</div>
|
|
1036
|
+
)}
|
|
1037
|
+
|
|
1038
|
+
{showGiftWrap && isDesigner && (
|
|
1039
|
+
<div
|
|
1040
|
+
data-basket-item-gift-wrap=""
|
|
1041
|
+
{...partAttrs('gift-wrap')}
|
|
1042
|
+
style={{ marginTop: '8px', opacity: 0.6 }}
|
|
1043
|
+
>
|
|
1044
|
+
{/* The plugin renders against a live basket only; the
|
|
1045
|
+
canvas shows where it lands. */}
|
|
1046
|
+
Gift wrap (plugin)
|
|
1047
|
+
</div>
|
|
1048
|
+
)}
|
|
1049
|
+
|
|
1050
|
+
{removePlacement === 'inline' && removeControl && (
|
|
1051
|
+
<div
|
|
1052
|
+
data-basket-item-remove-slot=""
|
|
1053
|
+
style={{ marginTop: '8px' }}
|
|
1054
|
+
>
|
|
1055
|
+
{removeControl}
|
|
1056
|
+
</div>
|
|
1057
|
+
)}
|
|
404
1058
|
</div>
|
|
405
1059
|
|
|
406
1060
|
<div
|
|
@@ -414,38 +1068,16 @@ const BasketItemsBlock = ({
|
|
|
414
1068
|
}}
|
|
415
1069
|
>
|
|
416
1070
|
{showTotal && (
|
|
417
|
-
<span
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
1071
|
+
<span data-basket-item-total="" {...partAttrs('line-total')}>
|
|
1072
|
+
{formatMoney(item.total_amount, {
|
|
1073
|
+
locale,
|
|
1074
|
+
currency,
|
|
1075
|
+
...money
|
|
1076
|
+
})}
|
|
422
1077
|
</span>
|
|
423
1078
|
)}
|
|
424
1079
|
|
|
425
|
-
{
|
|
426
|
-
<button
|
|
427
|
-
type="button"
|
|
428
|
-
data-basket-item-remove=""
|
|
429
|
-
aria-label={removeAriaLabel}
|
|
430
|
-
disabled={isUpdating}
|
|
431
|
-
// Quantity 0 is how the commerce basket endpoint deletes a line.
|
|
432
|
-
onClick={() => changeQuantity(item, 0)}
|
|
433
|
-
{...partAttrs('remove', { disabled: isUpdating })}
|
|
434
|
-
style={{
|
|
435
|
-
background: 'none',
|
|
436
|
-
border: 'none',
|
|
437
|
-
cursor: 'pointer',
|
|
438
|
-
color: 'inherit',
|
|
439
|
-
padding: 0
|
|
440
|
-
}}
|
|
441
|
-
>
|
|
442
|
-
{removeIcon === 'text' ? (
|
|
443
|
-
removeLabel
|
|
444
|
-
) : (
|
|
445
|
-
<RemoveGlyph icon={removeIcon} />
|
|
446
|
-
)}
|
|
447
|
-
</button>
|
|
448
|
-
)}
|
|
1080
|
+
{removePlacement === 'aside' && removeControl}
|
|
449
1081
|
</div>
|
|
450
1082
|
</li>
|
|
451
1083
|
);
|