@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.
@@ -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 { formatMoney } from './commerce-format';
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, price, quantity,
89
- * quantity-button, quantity-input, actions, line-total, remove, empty.
90
- * A child marked as the item template replaces the built-in row.
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.image || item.product?.image || '',
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
- price: formatMoney(item.unit_price || item.price, { locale, currency }),
195
- total: formatMoney(item.total_amount, { locale, currency }),
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.image || item.product?.image || '';
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 = showVariant
273
- ? formatVariant(item.attributes, variantSeparator)
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
- {showPrice && (
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
- {allowQuantityChange && (
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
- data-basket-item-total=""
419
- {...partAttrs('line-total')}
420
- >
421
- {formatMoney(item.total_amount, { locale, currency })}
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
- {allowRemove && (
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
  );