@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.
@@ -8,7 +8,7 @@ import { getResponsiveValue, partAttrs } from '../utils';
8
8
  import { useThemePageContext } from '../theme-page-context';
9
9
  import { formatMoney, parseAmount } from './commerce-format';
10
10
  import { hasItemTemplate, ItemTemplateList } from './item-template';
11
- import { readToggle } from './product-block-utils';
11
+ import { getLineImage, readToggle } from './product-block-utils';
12
12
  import { BlockRendererProps } from './block-renderer-registry';
13
13
 
14
14
  /** Live basket line, as `getBasket` returns it. */
@@ -20,7 +20,12 @@ interface BasketLineLike {
20
20
  unit_price?: string;
21
21
  total_amount?: string;
22
22
  currency_type?: string;
23
- product?: { pk?: number; name?: string; image?: string } | null;
23
+ product?: {
24
+ pk?: number;
25
+ name?: string;
26
+ image?: string;
27
+ productimage_set?: Array<{ image?: string; url?: string }>;
28
+ } | null;
24
29
  }
25
30
 
26
31
  interface BasketTotalsLike {
@@ -162,7 +167,7 @@ const CheckoutSummaryBlock = ({
162
167
  ? liveLines.map((item, index) => ({
163
168
  key: String(item.id ?? item.product?.pk ?? index),
164
169
  name: item.product?.name || '',
165
- image: item.image || item.product?.image || '',
170
+ image: getLineImage(item),
166
171
  quantity: Number(item.quantity ?? 1),
167
172
  total: String(item.total_amount ?? item.price ?? ''),
168
173
  // The line reports the currency it was priced in; the storefront
@@ -236,6 +236,7 @@ export const parsePriceValue = (value: unknown): number | null =>
236
236
 
237
237
  export interface OrderLineLike {
238
238
  id?: number;
239
+ image?: string;
239
240
  price?: string | number;
240
241
  quantity?: string | number;
241
242
  total_amount?: string | number;
@@ -244,9 +245,38 @@ export interface OrderLineLike {
244
245
  name?: string;
245
246
  image?: string;
246
247
  absolute_url?: string;
248
+ productimage_set?: ThemeProductImage[];
249
+ } | null;
250
+ }
251
+
252
+ /** A basket or order line as commerce sends it: the picture can sit on any of three fields. */
253
+ export interface CommerceLineLike {
254
+ image?: unknown;
255
+ product?: {
256
+ image?: unknown;
257
+ productimage_set?: ThemeProductImage[] | null;
247
258
  } | null;
248
259
  }
249
260
 
261
+ /**
262
+ * The image a basket/order line should show. Commerce leaves `line.image` and
263
+ * `product.image` unset on these payloads and carries the shot only in
264
+ * `product.productimage_set` — the page-context normaliser already falls back
265
+ * to it, but the blocks that prefer the live query over the context were
266
+ * reading the two empty fields and drawing no image at all.
267
+ */
268
+ export const getLineImage = (
269
+ line: CommerceLineLike | null | undefined
270
+ ): string => {
271
+ const product = line?.product;
272
+ const direct = toText(line?.image) || toText(product?.image);
273
+ if (direct) return direct;
274
+
275
+ const first = (product?.productimage_set || [])[0];
276
+
277
+ return toText(first?.image) || toText(first?.url);
278
+ };
279
+
250
280
  export interface GroupedOrderLine<T extends OrderLineLike> {
251
281
  /** Stable React key: the product pk when the payload carries one. */
252
282
  key: string;
@@ -300,7 +330,7 @@ export const groupOrderItems = <T extends OrderLineLike>(
300
330
  accumulator.push({
301
331
  key,
302
332
  name: item.product?.name || '',
303
- image: item.product?.image || '',
333
+ image: getLineImage(item),
304
334
  href: item.product?.absolute_url || '',
305
335
  price: String(item.price ?? ''),
306
336
  total: lineTotal,
@@ -9,6 +9,7 @@ import {
9
9
  } from '@akinon/next/data/client/product';
10
10
  import { useLocalization } from '@akinon/next/hooks';
11
11
  import { useOverlay } from '../hooks/use-overlay';
12
+ import { useThemeSectionData } from '../theme-section-data-context';
12
13
  import { getResponsiveValue, partAttrs } from '../utils';
13
14
  import { BlockRendererProps } from './block-renderer-registry';
14
15
  import { hasItemTemplate, ItemTemplateList } from './item-template';
@@ -18,6 +19,12 @@ import {
18
19
  listingPlaceholderStyle,
19
20
  readToggle
20
21
  } from './listing-context';
22
+ import {
23
+ MoneyCurrencyDisplay,
24
+ MoneyCurrencyPosition,
25
+ MoneyDecimals,
26
+ MoneyStyleOptions
27
+ } from './commerce-format';
21
28
  import {
22
29
  isProductInStock,
23
30
  isVariantOptionSoldOut,
@@ -40,9 +47,16 @@ import {
40
47
  const DEFAULT_CARD_GAP = 16;
41
48
 
42
49
  const ALLOWED_TITLE_TAGS = ['div', 'h2', 'h3', 'span'];
43
- const ALLOWED_IMAGE_RATIOS = ['', '1/1', '3/4', '4/3'];
50
+ const ALLOWED_IMAGE_RATIOS = ['', '1/1', '2/3', '3/4', '4/3'];
51
+ /** `stacked` is the title-above-arrows layout the block always drew. */
52
+ const ALLOWED_HEADER_LAYOUTS = ['stacked', 'inline'];
53
+ /** `chevron` is the stroked glyph the block always drew. */
54
+ const ALLOWED_ARROW_ICONS = ['chevron', 'arrow'];
55
+ const ALLOWED_DECIMALS = ['always', 'auto', 'never'];
56
+ const ALLOWED_CURRENCY_DISPLAYS = ['', 'symbol', 'code', 'custom', 'none'];
57
+ const ALLOWED_CURRENCY_POSITIONS = ['', 'before', 'after'];
44
58
  /** `collection` is the source the block has always had, so it is the default. */
45
- const ALLOWED_SOURCES = ['collection', 'recently-viewed'];
59
+ const ALLOWED_SOURCES = ['collection', 'recently-viewed', 'section'];
46
60
  const ALLOWED_QUICK_VIEW_ICONS = ['eye', 'plus', 'none'];
47
61
 
48
62
  const DEFAULT_QUICK_VIEW_FEEDBACK = 2000;
@@ -94,6 +108,29 @@ const safeUrl = (value: unknown): string => {
94
108
  return /^https?:\/\//i.test(text) ? text : '';
95
109
  };
96
110
 
111
+ /**
112
+ * The 8×8 line arrow of the editorial design, sized in em so the `arrow`
113
+ * part's font-size sets it.
114
+ */
115
+ const LineArrowIcon = ({ direction }: { direction: 'prev' | 'next' }) => (
116
+ <svg
117
+ width="1em"
118
+ height="1em"
119
+ viewBox="0 0 8 8"
120
+ fill="currentColor"
121
+ aria-hidden="true"
122
+ style={{ display: 'block' }}
123
+ >
124
+ <path
125
+ d={
126
+ direction === 'prev'
127
+ ? 'M5.85 0.78L2.67 3.96L5.85 7.15L5.15 7.85L1.26 3.96L5.15 0.08L5.85 0.78Z'
128
+ : 'M6.78 4.04L2.89 7.92L2.18 7.22L5.36 4.04L2.18 0.85L2.89 0.15L6.78 4.04Z'
129
+ }
130
+ />
131
+ </svg>
132
+ );
133
+
97
134
  const ChevronIcon = ({ direction }: { direction: 'prev' | 'next' }) => (
98
135
  <svg
99
136
  width="16"
@@ -180,7 +217,9 @@ interface RecommendationCard {
180
217
  * `source` picks where the cards come from. It defaults to `collection` — the
181
218
  * only behaviour that existed — and `recently-viewed` swaps in the products
182
219
  * this browser has looked at, which the block also records (see
183
- * `recently-viewed-store.ts` for what is stored and why).
220
+ * `recently-viewed-store.ts` for what is stored and why). `section` reads the
221
+ * rows the enclosing section's data source resolved to (the collection picked
222
+ * in the editor's Source panel), through `theme-section-data-context`.
184
223
  *
185
224
  * ACTING ON A CARD WITHOUT LEAVING THE PAGE comes in two halves, and only one
186
225
  * of them is built here.
@@ -202,7 +241,9 @@ interface RecommendationCard {
202
241
  * request is in flight. Off by default; every published strip renders exactly
203
242
  * as it did.
204
243
  *
205
- * Parts: title, arrow-list, arrow, track, card-slot, card, card-image,
244
+ * Parts: header (inline header layout only), title, arrow-list, arrow
245
+ * (`disabled` at the start / end of the track), track, card-slot, card,
246
+ * card-image,
206
247
  * card-name, card-price, quick-view-trigger, quick-view-backdrop,
207
248
  * quick-view-panel, quick-view-close, quick-view-media, quick-view-body,
208
249
  * quick-view-name, quick-view-price, quick-view-old-price, quick-view-status,
@@ -210,7 +251,9 @@ interface RecommendationCard {
210
251
  * quick-view-size, quick-view-add, quick-view-link.
211
252
  * Properties: source, title, titleTag, collectionId, limit, columnsDesktop,
212
253
  * columnsMobile, cardGap, showName, showPrice, imageRatio, showArrows,
213
- * prevLabel, nextLabel, recentlyViewedKey, recentlyViewedCapacity,
254
+ * prevLabel, nextLabel, headerLayout (stacked | inline), arrowIcon
255
+ * (chevron | arrow), decimals, currencyDisplay, currencyText,
256
+ * currencyPosition, recentlyViewedKey, recentlyViewedCapacity,
214
257
  * recentlyViewedMaxAge, recentlyViewedEmptyLabel, showQuickView,
215
258
  * quickViewTriggerLabel, quickViewTriggerIcon, quickViewWidth,
216
259
  * showQuickViewBackdrop, quickViewCloseOnBackdrop, quickViewCloseOnEscape,
@@ -261,6 +304,11 @@ const ProductRecommendationsBlock = ({
261
304
  ? sourceValue
262
305
  : 'collection';
263
306
  const isRecentlyViewed = source === 'recently-viewed';
307
+ // `section` draws the rows the SECTION's data source resolved to (the
308
+ // collection picked in the editor's Source panel), so the strip needs no
309
+ // collection id of its own.
310
+ const isSectionSource = source === 'section';
311
+ const sectionData = useThemeSectionData();
264
312
 
265
313
  const title = String(
266
314
  getResponsiveValue(block.properties?.title, currentBreakpoint, '')
@@ -321,6 +369,33 @@ const ProductRecommendationsBlock = ({
321
369
  );
322
370
  const prevLabel = prop('prevLabel', 'Previous products');
323
371
  const nextLabel = prop('nextLabel', 'Next products');
372
+ const headerLayoutValue = prop('headerLayout', 'stacked');
373
+ const headerLayout = ALLOWED_HEADER_LAYOUTS.includes(headerLayoutValue)
374
+ ? headerLayoutValue
375
+ : 'stacked';
376
+ const arrowIconValue = prop('arrowIcon', 'chevron');
377
+ const arrowIcon = ALLOWED_ARROW_ICONS.includes(arrowIconValue)
378
+ ? arrowIconValue
379
+ : 'chevron';
380
+ const decimalsValue = prop('decimals', 'always');
381
+ const currencyDisplayValue = prop('currencyDisplay', '');
382
+ const currencyPositionValue = prop('currencyPosition', '');
383
+ const money: MoneyStyleOptions = {
384
+ decimals: (ALLOWED_DECIMALS.includes(decimalsValue)
385
+ ? decimalsValue
386
+ : 'always') as MoneyDecimals,
387
+ currencyDisplay: (ALLOWED_CURRENCY_DISPLAYS.includes(currencyDisplayValue)
388
+ ? currencyDisplayValue
389
+ : '') as MoneyCurrencyDisplay,
390
+ currencyText: prop('currencyText', ''),
391
+ currencyPosition: (ALLOWED_CURRENCY_POSITIONS.includes(
392
+ currencyPositionValue
393
+ )
394
+ ? currencyPositionValue
395
+ : '') as MoneyCurrencyPosition
396
+ };
397
+ // Which way the track can still scroll; drives the arrows' disabled state.
398
+ const [canScroll, setCanScroll] = React.useState({ prev: false, next: true });
324
399
  const recentlyViewedKey =
325
400
  prop('recentlyViewedKey', DEFAULT_RECENTLY_VIEWED_KEY).trim() ||
326
401
  DEFAULT_RECENTLY_VIEWED_KEY;
@@ -435,9 +510,33 @@ const ProductRecommendationsBlock = ({
435
510
  // is what prevents a request before the author has named a collection — and
436
511
  // now also a request for a collection the recently-viewed source never uses.
437
512
  const { data, isFetching } = useGetWidgetQuery(collectionId, {
438
- skip: !collectionId || isRecentlyViewed
513
+ skip: !collectionId || isRecentlyViewed || isSectionSource
439
514
  });
440
515
 
516
+ // The arrows report where the track stands: the previous arrow is disabled
517
+ // at the start and the next one at the end, re-read on scroll and resize.
518
+ // In the designer both stay enabled so the author can style them.
519
+ React.useEffect(() => {
520
+ const track = trackRef.current;
521
+ if (!track || !showArrows || isDesigner) return;
522
+
523
+ const update = () => {
524
+ const maxScroll = track.scrollWidth - track.clientWidth;
525
+ setCanScroll({
526
+ prev: track.scrollLeft > 1,
527
+ next: track.scrollLeft < maxScroll - 1
528
+ });
529
+ };
530
+
531
+ update();
532
+ track.addEventListener('scroll', update, { passive: true });
533
+ window.addEventListener('resize', update);
534
+ return () => {
535
+ track.removeEventListener('scroll', update);
536
+ window.removeEventListener('resize', update);
537
+ };
538
+ }, [showArrows, isDesigner, data, recentEntries, sectionData]);
539
+
441
540
  /**
442
541
  * The overlay's own request. A row carries a summary only, so the variant
443
542
  * matrix a size picker needs exists nowhere on this page — it has to come
@@ -572,7 +671,9 @@ const ProductRecommendationsBlock = ({
572
671
  );
573
672
 
574
673
  const collectionProducts: RecommendationCard[] = (
575
- (data?.products || []) as Array<Record<string, unknown>>
674
+ (isSectionSource
675
+ ? sectionData?.items || []
676
+ : data?.products || []) as Array<Record<string, unknown>>
576
677
  )
577
678
  .slice(0, limit)
578
679
  // Annotated so the element type matches the predicate below: left inferred,
@@ -586,9 +687,17 @@ const ProductRecommendationsBlock = ({
586
687
  const images = Array.isArray(entry.productimage_set)
587
688
  ? (entry.productimage_set as Array<Record<string, unknown>>)
588
689
  : [];
690
+ // The section source hands over the editor's own rows in the designer,
691
+ // and those are Omnitron products: the money sits under `active_price`
692
+ // there, flat on the commerce payloads the storefront reads.
693
+ const activePrice = (entry.active_price ?? {}) as Record<string, unknown>;
589
694
  // Commerce prices each product in its own currency; the storefront
590
695
  // currency is only the fallback for payloads that omit it.
591
- const currency = toText(entry.currency_type) || localeCurrency || '';
696
+ const currency =
697
+ toText(entry.currency_type) ||
698
+ toText(activePrice.currency_type) ||
699
+ localeCurrency ||
700
+ '';
592
701
 
593
702
  return {
594
703
  key: toText(entry.pk) || `recommendation-${index}`,
@@ -596,7 +705,12 @@ const ProductRecommendationsBlock = ({
596
705
  name,
597
706
  url: url || '#',
598
707
  image: toText(entry.image) || toText(images[0]?.image),
599
- price: formatListingPrice(entry.price, currency, locale)
708
+ price: formatListingPrice(
709
+ entry.price ?? activePrice.price,
710
+ currency,
711
+ locale,
712
+ money
713
+ )
600
714
  };
601
715
  })
602
716
  .filter((card): card is RecommendationCard => card !== null);
@@ -621,7 +735,8 @@ const ProductRecommendationsBlock = ({
621
735
  price: formatListingPrice(
622
736
  entry.price,
623
737
  entry.currency || localeCurrency || '',
624
- locale
738
+ locale,
739
+ money
625
740
  )
626
741
  }));
627
742
 
@@ -678,7 +793,9 @@ const ProductRecommendationsBlock = ({
678
793
 
679
794
  return (
680
795
  <div data-product-recommendations="" style={listingPlaceholderStyle}>
681
- {!collectionId
796
+ {isSectionSource
797
+ ? 'Recommendations (pick a collection in the section Source panel)'
798
+ : !collectionId
682
799
  ? 'Recommendations (set a collection id)'
683
800
  : isFetching
684
801
  ? 'Recommendations (loading)'
@@ -707,6 +824,7 @@ const ProductRecommendationsBlock = ({
707
824
  });
708
825
  };
709
826
 
827
+
710
828
  const arrowStyle: React.CSSProperties = {
711
829
  display: 'inline-flex',
712
830
  alignItems: 'center',
@@ -933,49 +1051,89 @@ const ProductRecommendationsBlock = ({
933
1051
  )};}}`}
934
1052
  </style>
935
1053
 
936
- {title && (
937
- <TitleTag
938
- data-product-recommendations-title=""
939
- {...partAttrs('title')}
940
- style={{ marginBottom: '12px', fontWeight: 600 }}
941
- >
942
- {title}
943
- </TitleTag>
944
- )}
1054
+ {(() => {
1055
+ const inlineHeader = headerLayout === 'inline';
1056
+ const ArrowGlyph = arrowIcon === 'arrow' ? LineArrowIcon : ChevronIcon;
1057
+ const prevDisabled = !isDesigner && !canScroll.prev;
1058
+ const nextDisabled = !isDesigner && !canScroll.next;
945
1059
 
946
- {showArrows && (
947
- <div
948
- data-product-recommendations-arrows=""
949
- {...partAttrs('arrow-list')}
950
- style={{
951
- display: 'flex',
952
- justifyContent: 'flex-end',
953
- gap: '8px',
954
- marginBottom: '8px'
955
- }}
956
- >
957
- <button
958
- type="button"
959
- data-product-recommendations-arrow="prev"
960
- {...partAttrs('arrow')}
961
- aria-label={prevLabel}
962
- onClick={() => scrollTrack(-1)}
963
- style={arrowStyle}
1060
+ const titleNode = title ? (
1061
+ <TitleTag
1062
+ data-product-recommendations-title=""
1063
+ {...partAttrs('title')}
1064
+ style={{
1065
+ marginBottom: inlineHeader ? 0 : '12px',
1066
+ fontWeight: 600
1067
+ }}
964
1068
  >
965
- <ChevronIcon direction="prev" />
966
- </button>
967
- <button
968
- type="button"
969
- data-product-recommendations-arrow="next"
970
- {...partAttrs('arrow')}
971
- aria-label={nextLabel}
972
- onClick={() => scrollTrack(1)}
973
- style={arrowStyle}
1069
+ {title}
1070
+ </TitleTag>
1071
+ ) : null;
1072
+
1073
+ const arrowsNode = showArrows ? (
1074
+ <div
1075
+ data-product-recommendations-arrows=""
1076
+ {...partAttrs('arrow-list')}
1077
+ style={{
1078
+ display: 'flex',
1079
+ justifyContent: 'flex-end',
1080
+ gap: '8px',
1081
+ marginBottom: inlineHeader ? 0 : '8px'
1082
+ }}
974
1083
  >
975
- <ChevronIcon direction="next" />
976
- </button>
977
- </div>
978
- )}
1084
+ <button
1085
+ type="button"
1086
+ data-product-recommendations-arrow="prev"
1087
+ {...partAttrs('arrow', { disabled: prevDisabled })}
1088
+ aria-label={prevLabel}
1089
+ aria-disabled={prevDisabled || undefined}
1090
+ onClick={() => scrollTrack(-1)}
1091
+ style={arrowStyle}
1092
+ >
1093
+ <ArrowGlyph direction="prev" />
1094
+ </button>
1095
+ <button
1096
+ type="button"
1097
+ data-product-recommendations-arrow="next"
1098
+ {...partAttrs('arrow', { disabled: nextDisabled })}
1099
+ aria-label={nextLabel}
1100
+ aria-disabled={nextDisabled || undefined}
1101
+ onClick={() => scrollTrack(1)}
1102
+ style={arrowStyle}
1103
+ >
1104
+ <ArrowGlyph direction="next" />
1105
+ </button>
1106
+ </div>
1107
+ ) : null;
1108
+
1109
+ // `inline` puts the title and the arrows on one row (`header` part);
1110
+ // `stacked` is the element order the block always drew.
1111
+ if (inlineHeader && (titleNode || arrowsNode)) {
1112
+ return (
1113
+ <div
1114
+ data-product-recommendations-header=""
1115
+ {...partAttrs('header')}
1116
+ style={{
1117
+ display: 'flex',
1118
+ alignItems: 'center',
1119
+ justifyContent: 'space-between',
1120
+ gap: '16px',
1121
+ marginBottom: '12px'
1122
+ }}
1123
+ >
1124
+ {titleNode}
1125
+ {arrowsNode}
1126
+ </div>
1127
+ );
1128
+ }
1129
+
1130
+ return (
1131
+ <>
1132
+ {titleNode}
1133
+ {arrowsNode}
1134
+ </>
1135
+ );
1136
+ })()}
979
1137
 
980
1138
  <div
981
1139
  ref={trackRef}
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+
3
+ import { createContext, PropsWithChildren, useContext, useMemo } from 'react';
4
+
5
+ /**
6
+ * The rows a section's data source resolved to (a collection's products, for
7
+ * instance), for functional blocks that want to draw them with their own
8
+ * chrome — a scrolling strip with arrows — instead of the iterator/item
9
+ * template mechanism. Null outside a section, or in a section without one.
10
+ */
11
+ export interface ThemeSectionDataValue {
12
+ items: Record<string, unknown>[];
13
+ }
14
+
15
+ const ThemeSectionDataContext = createContext<ThemeSectionDataValue | null>(
16
+ null
17
+ );
18
+
19
+ export const ThemeSectionDataProvider = ({
20
+ items,
21
+ children
22
+ }: PropsWithChildren<{ items: Record<string, unknown>[] | null }>) => {
23
+ const value = useMemo(() => (items ? { items } : null), [items]);
24
+
25
+ return (
26
+ <ThemeSectionDataContext.Provider value={value}>
27
+ {children}
28
+ </ThemeSectionDataContext.Provider>
29
+ );
30
+ };
31
+
32
+ export const useThemeSectionData = () => useContext(ThemeSectionDataContext);
@@ -22,6 +22,7 @@ import {
22
22
  import { safeDeepClone } from './utils/safe-clone';
23
23
 
24
24
  import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
25
+ import { ThemeSectionDataProvider } from './theme-section-data-context';
25
26
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
26
27
  export interface Section {
27
28
  id: string;
@@ -210,6 +211,16 @@ export default function ThemeSection({
210
211
  return Array.isArray(value) ? (value as Record<string, unknown>[]) : [];
211
212
  };
212
213
 
214
+ // The section's own rows, handed to functional blocks through context (see
215
+ // theme-section-data-context.tsx). Computed once per render; the list is
216
+ // empty rather than null when the section has a data source with no rows.
217
+ const sectionDataItems = useMemo<Record<string, unknown>[] | null>(
218
+ () =>
219
+ effectiveSection.dataSource?.details ? getProductsFromDataPath() : null,
220
+ // eslint-disable-next-line react-hooks/exhaustive-deps
221
+ [effectiveSection.dataSource, isDesigner]
222
+ );
223
+
213
224
  const getIteratorRenderMeta = (iteratorBlock: Block) => {
214
225
  const dataPath =
215
226
  (iteratorBlock.iteratorDataPath as string | undefined) ||
@@ -957,6 +968,7 @@ export default function ThemeSection({
957
968
  };
958
969
 
959
970
  return (
971
+ <ThemeSectionDataProvider items={sectionDataItems}>
960
972
  <section
961
973
  ref={sectionRef}
962
974
  data-section-id={effectiveSection.id}
@@ -1289,5 +1301,6 @@ export default function ThemeSection({
1289
1301
  })}
1290
1302
  </div>
1291
1303
  </section>
1304
+ </ThemeSectionDataProvider>
1292
1305
  );
1293
1306
  }