@akinon/pz-theme 2.0.75-rc.0 → 2.0.76-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 -32
- package/jest/__mocks__/akinon-next-plugin-module.js +19 -3
- package/package.json +4 -4
- package/src/blocks/__tests__/basket-line-layout.test.tsx +174 -5
- package/src/blocks/__tests__/basket-line-price.test.tsx +24 -0
- package/src/blocks/__tests__/basket-offers.test.tsx +96 -1
- package/src/blocks/basket-items-block.tsx +204 -14
- package/src/blocks/basket-summary-block.tsx +223 -59
- package/src/chrome/__tests__/composition-source.test.ts +125 -0
- package/src/chrome/composition-source.ts +37 -10
- package/src/chrome/retrying-widget-data.ts +33 -0
- package/src/chrome/theme-config-source.ts +6 -7
- package/src/load-placeholder-sections.ts +4 -10
- package/src/utils/__tests__/color-alpha.test.ts +29 -0
- package/src/utils/index.ts +39 -3
- package/src/utils/style-resolution.test.ts +6 -2
|
@@ -68,6 +68,8 @@ const ALLOWED_CURRENCY_POSITIONS = ['', 'before', 'after'];
|
|
|
68
68
|
const VARIANT_LAYOUTS = ['inline', 'rows'];
|
|
69
69
|
const QUANTITY_CONTROLS = ['stepper', 'select'];
|
|
70
70
|
const REMOVE_PLACEMENTS = ['aside', 'inline'];
|
|
71
|
+
/** `plugin` keeps pz-basket-gift-pack's own icon + links UI. */
|
|
72
|
+
const GIFT_WRAP_PRESENTATIONS = ['plugin', 'checkbox'];
|
|
71
73
|
|
|
72
74
|
const toText = (value: unknown): string =>
|
|
73
75
|
typeof value === 'string'
|
|
@@ -173,8 +175,11 @@ const readOption = <T extends string>(
|
|
|
173
175
|
/**
|
|
174
176
|
* What a line is worth, read the way the brand storefronts read it: the
|
|
175
177
|
* struck "retail" figure comes from the price list (`retail_price` above
|
|
176
|
-
* `price`), the "in basket" figure from a basket
|
|
177
|
-
*
|
|
178
|
+
* `price`), the "in basket" figure from a basket CAMPAIGN that cut the
|
|
179
|
+
* line's own unit price (`unit_price` below `price`). `discount_amount` is
|
|
180
|
+
* deliberately not a signal — a basket-level voucher writes its share into
|
|
181
|
+
* it without touching the unit price, and reading it repeated the plain
|
|
182
|
+
* price as a "SEPETTE" badge the moment a promo code was applied.
|
|
178
183
|
*/
|
|
179
184
|
const describeLinePrice = (item: BasketLineLike) => {
|
|
180
185
|
const price = parseAmount(item.price ?? item.product?.price);
|
|
@@ -185,13 +190,9 @@ const describeLinePrice = (item: BasketLineLike) => {
|
|
|
185
190
|
discounted && retail! > 0
|
|
186
191
|
? Math.round(((retail! - price!) / retail!) * 100)
|
|
187
192
|
: 0;
|
|
188
|
-
const
|
|
189
|
-
const total = parseAmount(item.total_amount);
|
|
190
|
-
const basketDiscount = parseAmount(item.discount_amount) ?? 0;
|
|
193
|
+
const unit = parseAmount(item.unit_price);
|
|
191
194
|
const basketUnit =
|
|
192
|
-
|
|
193
|
-
? total / quantity
|
|
194
|
-
: null;
|
|
195
|
+
unit !== null && price !== null && unit < price ? unit : null;
|
|
195
196
|
|
|
196
197
|
return { price, retail, discounted, percent, basketUnit };
|
|
197
198
|
};
|
|
@@ -284,7 +285,9 @@ const RemoveGlyph = ({ icon }: { icon: string }) =>
|
|
|
284
285
|
* variant-row, variant-label, variant-value, price-row, retail-price, price
|
|
285
286
|
* (`selected` while a retail figure is struck), discount-badge,
|
|
286
287
|
* basket-price, quantity, quantity-button, quantity-input, quantity-select,
|
|
287
|
-
* quantity-select-icon, gift-wrap,
|
|
288
|
+
* quantity-select-icon, upsell-banner, gift-wrap, gift-wrap-toggle, gift-wrap-box,
|
|
289
|
+
* gift-wrap-label, controls (wraps quantity, gift wrap and
|
|
290
|
+
* the inline remove), remove-slot, actions, line-total, remove, empty. A child marked as the item
|
|
288
291
|
* template replaces the built-in row.
|
|
289
292
|
*
|
|
290
293
|
* Properties (beyond the toggles): variantLayout (inline | rows), variantRows
|
|
@@ -360,6 +363,99 @@ const BasketItemsBlock = ({
|
|
|
360
363
|
'aside'
|
|
361
364
|
);
|
|
362
365
|
const showGiftWrap = boolProp('showGiftWrap', false);
|
|
366
|
+
const showUpsellBanner = boolProp('showUpsellBanner', false);
|
|
367
|
+
const upsellBannerFormat = prop('upsellBannerFormat', '{message}');
|
|
368
|
+
const giftWrapPresentation = readOption<'plugin' | 'checkbox'>(
|
|
369
|
+
block.properties?.giftWrapPresentation,
|
|
370
|
+
currentBreakpoint,
|
|
371
|
+
GIFT_WRAP_PRESENTATIONS,
|
|
372
|
+
'plugin'
|
|
373
|
+
);
|
|
374
|
+
// Authored wording for the pz-basket-gift-pack plugin; an empty value
|
|
375
|
+
// keeps the plugin's own default for that string.
|
|
376
|
+
const giftWrapTranslations = Object.fromEntries(
|
|
377
|
+
(
|
|
378
|
+
[
|
|
379
|
+
['addGiftPack', 'giftWrapAddLabel'],
|
|
380
|
+
['giftPackAdded', 'giftWrapAddedLabel'],
|
|
381
|
+
['removeGiftPack', 'giftWrapRemoveLabel'],
|
|
382
|
+
['addNote', 'giftWrapNoteLabel'],
|
|
383
|
+
['placeholderInput', 'giftWrapNotePlaceholder'],
|
|
384
|
+
['save', 'giftWrapSaveLabel']
|
|
385
|
+
] as const
|
|
386
|
+
)
|
|
387
|
+
.map(([pluginKey, propKey]) => [pluginKey, prop(propKey, '').trim()])
|
|
388
|
+
.filter(([, value]) => Boolean(value))
|
|
389
|
+
);
|
|
390
|
+
const giftWrapCheckboxLabel =
|
|
391
|
+
prop('giftWrapAddLabel', '').trim() || 'Gift wrap';
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The checkbox presentation of the gift pack: a 17px box and the label,
|
|
395
|
+
* toggling the plugin's own add / remove handlers — the plugin still owns
|
|
396
|
+
* the request; only its default icon-and-links UI is replaced.
|
|
397
|
+
* Parts: gift-wrap-toggle, gift-wrap-box (`checked`), gift-wrap-label.
|
|
398
|
+
*/
|
|
399
|
+
const giftWrapCheckboxRow = (
|
|
400
|
+
checked: boolean,
|
|
401
|
+
onToggle: (() => void) | null
|
|
402
|
+
) => (
|
|
403
|
+
<button
|
|
404
|
+
type="button"
|
|
405
|
+
role="checkbox"
|
|
406
|
+
aria-checked={checked}
|
|
407
|
+
disabled={!onToggle}
|
|
408
|
+
onClick={(event) => {
|
|
409
|
+
event.preventDefault();
|
|
410
|
+
onToggle?.();
|
|
411
|
+
}}
|
|
412
|
+
{...partAttrs('gift-wrap-toggle', { checked })}
|
|
413
|
+
style={{
|
|
414
|
+
// Block-level flex on purpose: an inline-flex button reports a
|
|
415
|
+
// different baseline once the box has the checkmark in it, and the
|
|
416
|
+
// wrapper's line box grows or shrinks with the toggle.
|
|
417
|
+
display: 'flex',
|
|
418
|
+
alignItems: 'center',
|
|
419
|
+
gap: '10px',
|
|
420
|
+
padding: 0,
|
|
421
|
+
border: 'none',
|
|
422
|
+
background: 'none',
|
|
423
|
+
color: 'inherit',
|
|
424
|
+
font: 'inherit',
|
|
425
|
+
cursor: onToggle ? 'pointer' : 'default'
|
|
426
|
+
}}
|
|
427
|
+
>
|
|
428
|
+
<span
|
|
429
|
+
{...partAttrs('gift-wrap-box', { checked })}
|
|
430
|
+
style={{
|
|
431
|
+
display: 'inline-flex',
|
|
432
|
+
alignItems: 'center',
|
|
433
|
+
justifyContent: 'center',
|
|
434
|
+
width: '17px',
|
|
435
|
+
height: '17px',
|
|
436
|
+
flex: '0 0 auto',
|
|
437
|
+
background: '#ffffff',
|
|
438
|
+
border: '1px solid var(--basket-items-border, rgba(0, 0, 0, 0.07))'
|
|
439
|
+
}}
|
|
440
|
+
>
|
|
441
|
+
{checked && (
|
|
442
|
+
<svg
|
|
443
|
+
width="9"
|
|
444
|
+
height="8"
|
|
445
|
+
viewBox="0 0 8 8"
|
|
446
|
+
fill="currentColor"
|
|
447
|
+
aria-hidden="true"
|
|
448
|
+
style={{ display: 'block' }}
|
|
449
|
+
>
|
|
450
|
+
<path d="M7.54 1.33L3.14 5.73L3.14 5.73L2.51 6.36L1.88 5.73L0 3.85L0.63 3.22L2.51 5.1L6.91 0.7L7.54 1.33Z" />
|
|
451
|
+
</svg>
|
|
452
|
+
)}
|
|
453
|
+
</span>
|
|
454
|
+
<span {...partAttrs('gift-wrap-label', { checked })}>
|
|
455
|
+
{giftWrapCheckboxLabel}
|
|
456
|
+
</span>
|
|
457
|
+
</button>
|
|
458
|
+
);
|
|
363
459
|
const showRetailPrice = boolProp('showRetailPrice', false);
|
|
364
460
|
const showDiscountBadge = boolProp('showDiscountBadge', false);
|
|
365
461
|
const discountBadgeFormat = prop('discountBadgeFormat', '-{percent}%');
|
|
@@ -476,6 +572,43 @@ const BasketItemsBlock = ({
|
|
|
476
572
|
|
|
477
573
|
const items = maxItems > 0 ? allItems.slice(0, maxItems) : allItems;
|
|
478
574
|
|
|
575
|
+
type UpsellDetailLike = {
|
|
576
|
+
message?: string;
|
|
577
|
+
params?: {
|
|
578
|
+
required?: number;
|
|
579
|
+
partial?: number;
|
|
580
|
+
remaining?: number;
|
|
581
|
+
} | null;
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
// Commerce announces basket promotions in progress ("add 2 more items")
|
|
585
|
+
// through `upsell_details`; the banner only ever shows what the API sent,
|
|
586
|
+
// so it disappears on its own when no campaign is running.
|
|
587
|
+
const liveUpsells: UpsellDetailLike[] = showUpsellBanner
|
|
588
|
+
? (
|
|
589
|
+
(basket as { upsell_details?: UpsellDetailLike[] } | undefined)
|
|
590
|
+
?.upsell_details ?? []
|
|
591
|
+
).filter((entry) => entry && (entry.message || entry.params))
|
|
592
|
+
: [];
|
|
593
|
+
const upsells: UpsellDetailLike[] =
|
|
594
|
+
isDesigner && showUpsellBanner && liveUpsells.length === 0
|
|
595
|
+
? [
|
|
596
|
+
{
|
|
597
|
+
message: 'Add 2 more items to unlock the campaign',
|
|
598
|
+
params: { required: 3, partial: 1, remaining: 2 }
|
|
599
|
+
}
|
|
600
|
+
]
|
|
601
|
+
: liveUpsells;
|
|
602
|
+
|
|
603
|
+
const formatUpsell = (upsell: UpsellDetailLike) => {
|
|
604
|
+
const params = upsell.params ?? {};
|
|
605
|
+
return upsellBannerFormat
|
|
606
|
+
.replace(/\{message\}/g, upsell.message ?? '')
|
|
607
|
+
.replace(/\{remaining\}/g, String(params.remaining ?? ''))
|
|
608
|
+
.replace(/\{required\}/g, String(params.required ?? ''))
|
|
609
|
+
.replace(/\{partial\}/g, String(params.partial ?? ''));
|
|
610
|
+
};
|
|
611
|
+
|
|
479
612
|
const changeQuantity = (item: BasketLineLike, nextQuantity: number) => {
|
|
480
613
|
const productPk = item.product?.pk;
|
|
481
614
|
|
|
@@ -592,6 +725,16 @@ const BasketItemsBlock = ({
|
|
|
592
725
|
padding: 0
|
|
593
726
|
}}
|
|
594
727
|
>
|
|
728
|
+
{upsells.map((upsell, index) => (
|
|
729
|
+
<li
|
|
730
|
+
key={`upsell-${index}`}
|
|
731
|
+
data-basket-items-upsell=""
|
|
732
|
+
{...partAttrs('upsell-banner')}
|
|
733
|
+
style={{ listStyle: 'none' }}
|
|
734
|
+
>
|
|
735
|
+
{formatUpsell(upsell)}
|
|
736
|
+
</li>
|
|
737
|
+
))}
|
|
595
738
|
{customRows ??
|
|
596
739
|
items.map((item, index) => {
|
|
597
740
|
// The line reports the currency it was priced in; the storefront
|
|
@@ -714,7 +857,12 @@ const BasketItemsBlock = ({
|
|
|
714
857
|
|
|
715
858
|
return (
|
|
716
859
|
<li
|
|
717
|
-
|
|
860
|
+
// Not item.id: the commerce backend recreates the basket item
|
|
861
|
+
// (new id) whenever an attribute like the gift note is written,
|
|
862
|
+
// and an id key would remount the row — visibly re-loading the
|
|
863
|
+
// gift wrap plugin on every checkbox toggle. The product pk
|
|
864
|
+
// survives those rewrites.
|
|
865
|
+
key={item.product?.pk ?? item.id ?? index}
|
|
718
866
|
data-basket-item=""
|
|
719
867
|
{...partAttrs('item')}
|
|
720
868
|
style={{
|
|
@@ -912,6 +1060,18 @@ const BasketItemsBlock = ({
|
|
|
912
1060
|
</div>
|
|
913
1061
|
)}
|
|
914
1062
|
|
|
1063
|
+
{/* One container around the quantity control, the gift
|
|
1064
|
+
wrap slot and the inline remove, so a brand can lay the
|
|
1065
|
+
three out per breakpoint (the mobile design puts the
|
|
1066
|
+
remove beside the quantity box) without new markup. */}
|
|
1067
|
+
<div
|
|
1068
|
+
{...partAttrs('controls')}
|
|
1069
|
+
style={{
|
|
1070
|
+
display: 'flex',
|
|
1071
|
+
flexDirection: 'column',
|
|
1072
|
+
alignItems: 'flex-start'
|
|
1073
|
+
}}
|
|
1074
|
+
>
|
|
915
1075
|
{allowQuantityChange && quantityControl === 'select' && (
|
|
916
1076
|
<div
|
|
917
1077
|
data-basket-item-quantity=""
|
|
@@ -1030,7 +1190,29 @@ const BasketItemsBlock = ({
|
|
|
1030
1190
|
>
|
|
1031
1191
|
<PluginModule
|
|
1032
1192
|
component={Component.BasketGiftPack}
|
|
1033
|
-
props={{
|
|
1193
|
+
props={{
|
|
1194
|
+
basketItem: item,
|
|
1195
|
+
translations: giftWrapTranslations,
|
|
1196
|
+
...(giftWrapPresentation === 'checkbox'
|
|
1197
|
+
? {
|
|
1198
|
+
customUIRender: ({
|
|
1199
|
+
hasGiftPack,
|
|
1200
|
+
onAddGiftPack,
|
|
1201
|
+
onRemoveGiftPack
|
|
1202
|
+
}: {
|
|
1203
|
+
hasGiftPack: boolean;
|
|
1204
|
+
onAddGiftPack: () => void;
|
|
1205
|
+
onRemoveGiftPack: () => void;
|
|
1206
|
+
}) =>
|
|
1207
|
+
giftWrapCheckboxRow(
|
|
1208
|
+
hasGiftPack,
|
|
1209
|
+
hasGiftPack
|
|
1210
|
+
? onRemoveGiftPack
|
|
1211
|
+
: onAddGiftPack
|
|
1212
|
+
)
|
|
1213
|
+
}
|
|
1214
|
+
: {})
|
|
1215
|
+
}}
|
|
1034
1216
|
/>
|
|
1035
1217
|
</div>
|
|
1036
1218
|
)}
|
|
@@ -1039,22 +1221,30 @@ const BasketItemsBlock = ({
|
|
|
1039
1221
|
<div
|
|
1040
1222
|
data-basket-item-gift-wrap=""
|
|
1041
1223
|
{...partAttrs('gift-wrap')}
|
|
1042
|
-
style={{
|
|
1224
|
+
style={{
|
|
1225
|
+
marginTop: '8px',
|
|
1226
|
+
opacity: giftWrapPresentation === 'checkbox' ? 1 : 0.6
|
|
1227
|
+
}}
|
|
1043
1228
|
>
|
|
1044
1229
|
{/* The plugin renders against a live basket only; the
|
|
1045
|
-
canvas shows
|
|
1046
|
-
|
|
1230
|
+
canvas shows the checkbox itself, or where the
|
|
1231
|
+
plugin's own UI lands. */}
|
|
1232
|
+
{giftWrapPresentation === 'checkbox'
|
|
1233
|
+
? giftWrapCheckboxRow(false, null)
|
|
1234
|
+
: 'Gift wrap (plugin)'}
|
|
1047
1235
|
</div>
|
|
1048
1236
|
)}
|
|
1049
1237
|
|
|
1050
1238
|
{removePlacement === 'inline' && removeControl && (
|
|
1051
1239
|
<div
|
|
1052
1240
|
data-basket-item-remove-slot=""
|
|
1241
|
+
{...partAttrs('remove-slot')}
|
|
1053
1242
|
style={{ marginTop: '8px' }}
|
|
1054
1243
|
>
|
|
1055
1244
|
{removeControl}
|
|
1056
1245
|
</div>
|
|
1057
1246
|
)}
|
|
1247
|
+
</div>
|
|
1058
1248
|
</div>
|
|
1059
1249
|
|
|
1060
1250
|
<div
|