@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.
- package/CHANGELOG.md +7 -31
- package/jest/__mocks__/akinon-next-plugin-module.js +18 -0
- package/jest.config.js +2 -0
- package/package.json +4 -4
- package/src/blocks/__tests__/basket-line-image.test.tsx +126 -0
- package/src/blocks/__tests__/basket-line-layout.test.tsx +336 -0
- package/src/blocks/__tests__/basket-line-price.test.tsx +227 -0
- package/src/blocks/__tests__/basket-offers.test.tsx +198 -0
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +169 -0
- package/src/blocks/basket-items-block.tsx +677 -45
- package/src/blocks/basket-summary-block.tsx +636 -92
- package/src/blocks/checkout-summary-block.tsx +8 -3
- package/src/blocks/product-block-utils.tsx +31 -1
- package/src/blocks/product-recommendations-block.tsx +209 -51
- package/src/theme-section-data-context.tsx +32 -0
- package/src/theme-section.tsx +13 -0
|
@@ -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?: {
|
|
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
|
|
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
|
|
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:
|
|
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,
|
|
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
|
-
(
|
|
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 =
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
{
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
966
|
-
</
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
style={
|
|
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
|
-
<
|
|
976
|
-
|
|
977
|
-
|
|
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);
|
package/src/theme-section.tsx
CHANGED
|
@@ -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
|
}
|