@akinon/pz-theme 2.0.163-rc.0 → 2.0.164-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.
Files changed (41) hide show
  1. package/CHANGELOG.md +15 -50
  2. package/package.json +4 -4
  3. package/src/__tests__/commerce-channel-frozen.test.ts +12 -1
  4. package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
  5. package/src/__tests__/section-list-identity.test.tsx +113 -0
  6. package/src/blocks/__tests__/favourites-grid-impression.test.tsx +217 -0
  7. package/src/blocks/__tests__/group-block-list-click.test.tsx +90 -0
  8. package/src/blocks/__tests__/text-block.test.tsx +6 -34
  9. package/src/blocks/favourites-grid-block.tsx +16 -18
  10. package/src/blocks/group-block.tsx +14 -2
  11. package/src/blocks/item-template.tsx +1 -0
  12. package/src/blocks/order-detail-block.tsx +95 -113
  13. package/src/blocks/product-block-utils.tsx +5 -1
  14. package/src/blocks/product-recommendations-block.tsx +45 -13
  15. package/src/blocks/text-block.tsx +2 -6
  16. package/src/custom/README.md +219 -100
  17. package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
  18. package/src/custom/collect.ts +9 -4
  19. package/src/custom/property-types.ts +15 -15
  20. package/src/custom/use-theme-setting.ts +5 -21
  21. package/src/editor-locale-bridge.tsx +11 -39
  22. package/src/hooks/__tests__/use-list-impression.test.tsx +228 -0
  23. package/src/hooks/__tests__/use-section-seen.test.tsx +55 -2
  24. package/src/hooks/use-list-impression.ts +116 -0
  25. package/src/hooks/use-section-seen.ts +22 -2
  26. package/src/sections/__tests__/posts-slider-select-item.test.tsx +205 -0
  27. package/src/sections/__tests__/section-wrapper-list.test.tsx +148 -0
  28. package/src/sections/posts-slider-section.tsx +62 -2
  29. package/src/sections/section-wrapper.tsx +32 -2
  30. package/src/theme-block.tsx +6 -0
  31. package/src/theme-placeholder-wrapper.tsx +0 -3
  32. package/src/theme-section-data-context.tsx +33 -0
  33. package/src/theme-section.tsx +60 -2
  34. package/src/utils/__tests__/section-impressions.test.ts +68 -0
  35. package/src/utils/iterator-price-shapes.test.ts +13 -0
  36. package/src/utils/iterator-utils.ts +2 -1
  37. package/src/utils/section-impressions.ts +37 -0
  38. package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
  39. package/src/blocks/order-line-actions.ts +0 -45
  40. package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
  41. package/src/utils/sanitize-rich-text.ts +0 -215
@@ -25,6 +25,7 @@ import {
25
25
  readItemsCurrency
26
26
  } from '../utils/analytics';
27
27
  import { hasItemTemplate, ItemTemplateList } from './item-template';
28
+ import { useListImpression } from '../hooks/use-list-impression';
28
29
  import { BlockRendererProps } from './block-renderer-registry';
29
30
  import { DesignerPlaceholder, readToggle } from './product-block-utils';
30
31
 
@@ -283,25 +284,21 @@ const FavouritesGridBlock = ({
283
284
 
284
285
  // GA4 view_item_list (ZERO-4869), once per distinct wishlist content: the
285
286
  // favourites grid IS the list impression on this screen. Keyed by row keys
286
- // so a remove refires with the new list, a mere re-render does not. A ref,
287
- // not a global — each visit mounts a fresh grid and must report again.
287
+ // so a remove refires with the new list, a mere re-render does not. Sent
288
+ // when the grid is seen, as every other list is (ZERO-5305): a grid kept
289
+ // only for its empty state, its list laid out with `display: none` next to
290
+ // a favourites strip, used to report the favourites a second time.
288
291
  const rowsKey = rows.map((row) => row.key).join(',');
289
- const pushedListKey = React.useRef<string | null>(null);
290
- React.useEffect(() => {
291
- if (isDesigner || rows.length === 0) return;
292
- if (pushedListKey.current === rowsKey) return;
293
- pushedListKey.current = rowsKey;
294
- emitCommerceEvent('view_item_list', {
295
- currency: localeCurrency,
296
- items: rows.map((row) =>
297
- buildCommerceItem(row as Record<string, unknown>, {
298
- listId: 'favourites',
299
- listName: 'Favourites'
300
- })
301
- )
302
- });
303
- // eslint-disable-next-line react-hooks/exhaustive-deps
304
- }, [rowsKey, isDesigner]);
292
+ const listRef = React.useRef<HTMLDivElement>(null);
293
+ useListImpression({
294
+ ref: listRef,
295
+ products: rows as Array<Record<string, unknown>>,
296
+ listId: 'favourites',
297
+ listName: 'Favourites',
298
+ enabled: !isDesigner,
299
+ refreshKey: rowsKey,
300
+ currency: localeCurrency
301
+ });
305
302
 
306
303
  // A designer who marked a child as the card layout gets that layout repeated
307
304
  // per saved product; otherwise the built-in card renders unchanged.
@@ -402,6 +399,7 @@ const FavouritesGridBlock = ({
402
399
 
403
400
  return (
404
401
  <div
402
+ ref={listRef}
405
403
  data-favourites-grid=""
406
404
  data-favourites-grid-id={gridId}
407
405
  aria-busy={isBusy}
@@ -17,6 +17,7 @@ import { DesignerPlaceholder, useThemeItemScope } from './product-block-utils';
17
17
  import { renderComposedChildren } from './render-composed-children';
18
18
  import { CardListSlider, readListGap } from '../components/card-list-slider';
19
19
  import { CardListViews } from '../components/card-list-views';
20
+ import { useThemeSectionList } from '../theme-section-data-context';
20
21
 
21
22
  /**
22
23
  * Composed anchors to the storefront's own routes navigate client-side. A
@@ -59,17 +60,28 @@ const GroupBlock = ({
59
60
  const Tag = tag as keyof React.JSX.IntrinsicElements;
60
61
  const href = tag === 'a' ? block.properties?.href : undefined;
61
62
  const itemScope = useThemeItemScope();
63
+ const sectionList = useThemeSectionList();
62
64
 
63
65
  // GA4 select_item (ZERO-4869): a composed anchor inside a repeated product
64
66
  // row IS the product card's click. The row scope carries the product, so no
65
- // per-card wiring is needed — and outside a row this stays silent.
67
+ // per-card wiring is needed — and outside a row this stays silent. The card
68
+ // reports the list it sits in — the section's — and its place in it
69
+ // (ZERO-5305), the same identity the row's view_item_list carries.
66
70
  const handleAnchorAnalytics = () => {
67
71
  if (isDesigner) return;
68
72
  const scopedProduct = itemScope?.product;
69
73
  if (!scopedProduct) return;
70
74
  emitCommerceEvent('select_item', {
71
75
  currency: readItemsCurrency(scopedProduct as Record<string, unknown>),
72
- items: [buildCommerceItem(scopedProduct as Record<string, unknown>)]
76
+ items: [
77
+ buildCommerceItem(scopedProduct as Record<string, unknown>, {
78
+ ...(typeof itemScope?.index === 'number'
79
+ ? { index: itemScope.index }
80
+ : null),
81
+ ...(sectionList?.listId ? { listId: sectionList.listId } : null),
82
+ ...(sectionList?.listName ? { listName: sectionList.listName } : null)
83
+ })
84
+ ]
73
85
  });
74
86
  };
75
87
 
@@ -158,6 +158,7 @@ export const ItemTemplateList = ({
158
158
  product={
159
159
  scoped ?? (isDesigner ? DESIGNER_SAMPLE_PRODUCT : null)
160
160
  }
161
+ index={index}
161
162
  >
162
163
  {row}
163
164
  </ThemeItemScopeProvider>
@@ -9,7 +9,6 @@ import { getResponsiveValue, partAttrs } from '../utils';
9
9
  import { formatDate, formatMoney } from './commerce-format';
10
10
  import { readMoneyStyle } from './money-style';
11
11
  import { hasItemTemplate, ItemTemplateList } from './item-template';
12
- import { renderOrderLineActions } from './order-line-actions';
13
12
  import { BlockRendererProps } from './block-renderer-registry';
14
13
  import {
15
14
  DesignerPlaceholder,
@@ -25,7 +24,7 @@ import {
25
24
  useOrderActionRules
26
25
  } from '../utils/order-action-rules';
27
26
 
28
- type OrderItemLike = {
27
+ interface OrderItemLike {
29
28
  id?: number;
30
29
  price?: string;
31
30
  quantity?: string | number;
@@ -44,7 +43,7 @@ type OrderItemLike = {
44
43
  absolute_url?: string;
45
44
  attributes?: Record<string, unknown>;
46
45
  } | null;
47
- };
46
+ }
48
47
 
49
48
  interface AddressLike {
50
49
  first_name?: string;
@@ -409,7 +408,7 @@ const formatAddress = (address: AddressLike | null | undefined): string[] => {
409
408
  * currencyText, currencyPosition.
410
409
  * Parts: detail, header, number, date, header-total, status,
411
410
  * invoice-link, cancel-link, return-link, list, item, image, name, quantity,
412
- * color, size, item-status, item-tracking, item-return-code, item-actions,
411
+ * color, size, item-status, item-tracking, item-return-code,
413
412
  * item-return-method, item-return-carrier, item-pickup-date, price, shipment,
414
413
  * delivery, tracking, addresses, address, address-label, totals, total-row,
415
414
  * total-label, total-value, grand-total, payment-info, payment-info-label.
@@ -956,21 +955,6 @@ const OrderDetailBlock = ({
956
955
  showPickupDate && returnDetails.pickupDate
957
956
  ? formatPickupDate(returnDetails.pickupDate, locale)
958
957
  : '';
959
- const lineActions = renderOrderLineActions({
960
- order,
961
- line,
962
- isDesigner
963
- });
964
- const showLineMeta =
965
- showShipmentStatus &&
966
- Boolean(
967
- item.status?.label ||
968
- trackingValue ||
969
- returnCodeValue ||
970
- returnMethodValue ||
971
- returnCarrierValue ||
972
- pickupDateValue
973
- );
974
958
 
975
959
  return (
976
960
  <li
@@ -1086,100 +1070,98 @@ const OrderDetailBlock = ({
1086
1070
  </div>
1087
1071
  </div>
1088
1072
 
1089
- {(showLineMeta || lineActions) && (
1090
- <div
1091
- style={{
1092
- display: 'flex',
1093
- flexDirection: 'column',
1094
- alignItems: isMobile ? 'flex-start' : 'flex-end',
1095
- textAlign: isMobile ? 'left' : 'right',
1096
- gap: '4px',
1097
- flex: isMobile ? '1 1 100%' : '0 0 auto'
1098
- }}
1099
- >
1100
- {lineActions && (
1101
- <div
1102
- data-order-detail-item-actions=""
1103
- {...partAttrs('item-actions')}
1104
- >
1105
- {lineActions}
1106
- </div>
1107
- )}
1108
- {showLineMeta && item.status?.label && (
1109
- <span
1110
- data-order-detail-item-status=""
1111
- data-order-status-value={item.status.value}
1112
- {...partAttrs('item-status')}
1113
- style={{ display: 'inline-block' }}
1114
- >
1115
- {item.status.label}
1116
- </span>
1117
- )}
1118
- {/* Label and value in their own spans, so a
1119
- composition can colour them apart. */}
1120
- {showLineMeta && returnMethodValue && (
1121
- <span
1122
- data-order-detail-item-return-method=""
1123
- {...partAttrs('item-return-method')}
1124
- style={{ opacity: 0.8 }}
1125
- >
1126
- <span data-label="">{returnMethodLabel}</span>{' '}
1127
- <span data-value="">{returnMethodValue}</span>
1128
- </span>
1129
- )}
1130
- {showLineMeta && returnCarrierValue && (
1131
- <span
1132
- data-order-detail-item-return-carrier=""
1133
- {...partAttrs('item-return-carrier')}
1134
- style={{ opacity: 0.8 }}
1135
- >
1136
- <span data-label="">{returnCarrierLabel}</span>{' '}
1137
- <span data-value="">{returnCarrierValue}</span>
1138
- </span>
1139
- )}
1140
- {showLineMeta && pickupDateValue && (
1141
- <span
1142
- data-order-detail-item-pickup-date=""
1143
- {...partAttrs('item-pickup-date')}
1144
- style={{ opacity: 0.8 }}
1145
- >
1146
- <span data-label="">{pickupDateLabel}</span>{' '}
1147
- <span data-value="">{pickupDateValue}</span>
1148
- </span>
1149
- )}
1150
- {showLineMeta && trackingValue && (
1151
- <span
1152
- data-order-detail-item-tracking=""
1153
- {...partAttrs('item-tracking')}
1154
- style={{ opacity: 0.8 }}
1155
- >
1156
- {lineTrackingLabel}{' '}
1157
- {item.tracking_url ? (
1158
- <a
1159
- href={isDesigner ? '#' : item.tracking_url}
1160
- onClick={guard}
1161
- target={isDesigner ? undefined : '_blank'}
1162
- rel="noreferrer"
1163
- style={{ color: 'inherit' }}
1164
- >
1165
- {item.tracking_number || item.tracking_url}
1166
- </a>
1167
- ) : (
1168
- trackingValue
1169
- )}
1170
- </span>
1171
- )}
1172
- {showLineMeta && returnCodeValue && (
1173
- <span
1174
- data-order-detail-item-return-code=""
1175
- {...partAttrs('item-return-code')}
1176
- style={{ opacity: 0.8 }}
1177
- >
1178
- {returnCodeLabel} {returnCodeValue}
1179
- </span>
1180
- )}
1181
- </div>
1182
- )}
1073
+ {showShipmentStatus &&
1074
+ (item.status?.label ||
1075
+ trackingValue ||
1076
+ returnCodeValue ||
1077
+ returnMethodValue ||
1078
+ returnCarrierValue ||
1079
+ pickupDateValue) && (
1080
+ <div
1081
+ style={{
1082
+ display: 'flex',
1083
+ flexDirection: 'column',
1084
+ alignItems: isMobile ? 'flex-start' : 'flex-end',
1085
+ textAlign: isMobile ? 'left' : 'right',
1086
+ gap: '4px',
1087
+ flex: isMobile ? '1 1 100%' : '0 0 auto'
1088
+ }}
1089
+ >
1090
+ {item.status?.label && (
1091
+ <span
1092
+ data-order-detail-item-status=""
1093
+ data-order-status-value={item.status.value}
1094
+ {...partAttrs('item-status')}
1095
+ style={{ display: 'inline-block' }}
1096
+ >
1097
+ {item.status.label}
1098
+ </span>
1099
+ )}
1100
+ {/* Label and value in their own spans, so a
1101
+ composition can colour them apart. */}
1102
+ {returnMethodValue && (
1103
+ <span
1104
+ data-order-detail-item-return-method=""
1105
+ {...partAttrs('item-return-method')}
1106
+ style={{ opacity: 0.8 }}
1107
+ >
1108
+ <span data-label="">{returnMethodLabel}</span>{' '}
1109
+ <span data-value="">{returnMethodValue}</span>
1110
+ </span>
1111
+ )}
1112
+ {returnCarrierValue && (
1113
+ <span
1114
+ data-order-detail-item-return-carrier=""
1115
+ {...partAttrs('item-return-carrier')}
1116
+ style={{ opacity: 0.8 }}
1117
+ >
1118
+ <span data-label="">{returnCarrierLabel}</span>{' '}
1119
+ <span data-value="">{returnCarrierValue}</span>
1120
+ </span>
1121
+ )}
1122
+ {pickupDateValue && (
1123
+ <span
1124
+ data-order-detail-item-pickup-date=""
1125
+ {...partAttrs('item-pickup-date')}
1126
+ style={{ opacity: 0.8 }}
1127
+ >
1128
+ <span data-label="">{pickupDateLabel}</span>{' '}
1129
+ <span data-value="">{pickupDateValue}</span>
1130
+ </span>
1131
+ )}
1132
+ {trackingValue && (
1133
+ <span
1134
+ data-order-detail-item-tracking=""
1135
+ {...partAttrs('item-tracking')}
1136
+ style={{ opacity: 0.8 }}
1137
+ >
1138
+ {lineTrackingLabel}{' '}
1139
+ {item.tracking_url ? (
1140
+ <a
1141
+ href={isDesigner ? '#' : item.tracking_url}
1142
+ onClick={guard}
1143
+ target={isDesigner ? undefined : '_blank'}
1144
+ rel="noreferrer"
1145
+ style={{ color: 'inherit' }}
1146
+ >
1147
+ {item.tracking_number || item.tracking_url}
1148
+ </a>
1149
+ ) : (
1150
+ trackingValue
1151
+ )}
1152
+ </span>
1153
+ )}
1154
+ {returnCodeValue && (
1155
+ <span
1156
+ data-order-detail-item-return-code=""
1157
+ {...partAttrs('item-return-code')}
1158
+ style={{ opacity: 0.8 }}
1159
+ >
1160
+ {returnCodeLabel} {returnCodeValue}
1161
+ </span>
1162
+ )}
1163
+ </div>
1164
+ )}
1183
1165
  </div>
1184
1166
  </li>
1185
1167
  );
@@ -99,6 +99,8 @@ export interface ThemeProduct {
99
99
  */
100
100
  interface ThemeItemScopeValue {
101
101
  product: ThemeProduct | null;
102
+ /** The row's place in its list (0-based), when the repeat knows it. */
103
+ index?: number;
102
104
  }
103
105
 
104
106
  const ThemeItemScopeContext = React.createContext<ThemeItemScopeValue | null>(
@@ -107,12 +109,14 @@ const ThemeItemScopeContext = React.createContext<ThemeItemScopeValue | null>(
107
109
 
108
110
  export const ThemeItemScopeProvider = ({
109
111
  product,
112
+ index,
110
113
  children
111
114
  }: {
112
115
  product: ThemeProduct | null;
116
+ index?: number;
113
117
  children: React.ReactNode;
114
118
  }) => {
115
- const value = React.useMemo(() => ({ product }), [product]);
119
+ const value = React.useMemo(() => ({ product, index }), [product, index]);
116
120
 
117
121
  return (
118
122
  <ThemeItemScopeContext.Provider value={value}>
@@ -11,7 +11,12 @@ import {
11
11
  } from '@akinon/next/data/client/product';
12
12
  import { useLocalization } from '@akinon/next/hooks';
13
13
  import { useOverlay } from '../hooks/use-overlay';
14
- import { useThemeSectionData } from '../theme-section-data-context';
14
+ import {
15
+ ThemeSectionListProvider,
16
+ useThemeSectionData,
17
+ useThemeSectionList
18
+ } from '../theme-section-data-context';
19
+ import { useListImpression } from '../hooks/use-list-impression';
15
20
  import { getResponsiveValue, partAttrs } from '../utils';
16
21
  import {
17
22
  buildCommerceItem,
@@ -285,6 +290,7 @@ const ProductRecommendationsBlock = ({
285
290
  }: BlockRendererProps) => {
286
291
  const { locale, currency: localeCurrency } = useLocalization();
287
292
  const rootRef = useRef<HTMLDivElement>(null);
293
+ const sectionList = useThemeSectionList();
288
294
  const trackRef = useRef<HTMLDivElement>(null);
289
295
  // The ambient product: on a PDP this is what the shopper is looking at, and
290
296
  // it is both what gets recorded and what is excluded from its own strip.
@@ -863,6 +869,25 @@ const ProductRecommendationsBlock = ({
863
869
  ? favouriteCards
864
870
  : collectionProducts;
865
871
 
872
+ // The list this strip is (ZERO-5305): named after its title ("You may also
873
+ // like"), else the section's label; identified by the
874
+ // collection it shows, else the section.
875
+ const listIdentity = {
876
+ listId:
877
+ (!isRecentlyViewed && !isFavourites && !isSectionSource && collectionId) ||
878
+ sectionList?.listId ||
879
+ 'product_recommendations',
880
+ listName: title || sectionList?.listName || 'Product Recommendations'
881
+ };
882
+
883
+ useListImpression({
884
+ ref: rootRef,
885
+ products: products as unknown as Record<string, unknown>[],
886
+ listId: listIdentity.listId,
887
+ listName: listIdentity.listName,
888
+ enabled: !isDesigner
889
+ });
890
+
866
891
  /**
867
892
  * The designer has no browsing history behind the canvas, so a recently
868
893
  * viewed strip would collapse to nothing and stop being selectable, sizeable
@@ -891,17 +916,24 @@ const ProductRecommendationsBlock = ({
891
916
 
892
917
  // A designer who marked a child as the card layout gets that layout repeated
893
918
  // per product; otherwise the built-in card renders unchanged.
919
+ // The strip's own list identity rides over its cards, so an authored card's
920
+ // click reports this strip — not the section around it — as its list.
894
921
  const customCards = hasItemTemplate(block, currentBreakpoint) ? (
895
- <ItemTemplateList
896
- block={block}
897
- items={products as unknown as Record<string, unknown>[]}
898
- placeholderId={placeholderId}
899
- sectionId={sectionId}
900
- isDesigner={isDesigner}
901
- selectedBlockId={selectedBlockId}
902
- currentBreakpoint={currentBreakpoint}
903
- designerPlaceholderCount={4}
904
- />
922
+ <ThemeSectionListProvider
923
+ listId={listIdentity.listId}
924
+ listName={listIdentity.listName}
925
+ >
926
+ <ItemTemplateList
927
+ block={block}
928
+ items={products as unknown as Record<string, unknown>[]}
929
+ placeholderId={placeholderId}
930
+ sectionId={sectionId}
931
+ isDesigner={isDesigner}
932
+ selectedBlockId={selectedBlockId}
933
+ currentBreakpoint={currentBreakpoint}
934
+ designerPlaceholderCount={4}
935
+ />
936
+ </ThemeSectionListProvider>
905
937
  ) : null;
906
938
 
907
939
  // An empty history is the normal state on a first visit, so the strip
@@ -1294,8 +1326,8 @@ const ProductRecommendationsBlock = ({
1294
1326
  buildCommerceItem(
1295
1327
  product as unknown as Record<string, unknown>,
1296
1328
  {
1297
- listId: 'product_recommendations',
1298
- listName: 'Product Recommendations'
1329
+ ...listIdentity,
1330
+ index: products.indexOf(product)
1299
1331
  }
1300
1332
  )
1301
1333
  ]
@@ -7,7 +7,6 @@ import { getInnerRootInlineStyles, partAttrs } from '../utils';
7
7
  import { useThemeSettingsContext } from '../theme-settings-context';
8
8
  import { useLocalizeHref } from '../hooks/use-localize-href';
9
9
  import { localizeHtmlLinks } from '../utils/localize-href';
10
- import { useValueLocale } from '../utils/localized-value';
11
10
 
12
11
  const TextBlock = ({
13
12
  block,
@@ -15,7 +14,7 @@ const TextBlock = ({
15
14
  }: BlockRendererProps) => {
16
15
  const { locale } = useLocalization();
17
16
  const localize = useLocalizeHref();
18
- const { defaultLocale } = useValueLocale();
17
+ const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE || 'en';
19
18
  const themeSettings = useThemeSettingsContext();
20
19
 
21
20
  const getLocalizedContent = (): string => {
@@ -31,10 +30,7 @@ const TextBlock = ({
31
30
 
32
31
  if (typeof value === 'object' && value !== null) {
33
32
  return (
34
- value[locale] ||
35
- (defaultLocale && value[defaultLocale]) ||
36
- Object.values(value)[0] ||
37
- ''
33
+ value[locale] || value[defaultLocale] || Object.values(value)[0] || ''
38
34
  );
39
35
  }
40
36