@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.
@@ -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 campaign that cut the line
177
- * (`discount_amount` > 0 → `total_amount / quantity`).
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 quantity = Number(item.quantity ?? 0);
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
- basketDiscount > 0 && total !== null && quantity > 0
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, actions, line-total, remove, empty. A child marked as the item
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
- key={item.id ?? item.product?.pk ?? index}
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={{ basketItem: item }}
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={{ marginTop: '8px', opacity: 0.6 }}
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 where it lands. */}
1046
- Gift wrap (plugin)
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