@akinon/pz-theme 2.0.143-beta.0 → 2.0.143

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 CHANGED
@@ -1,12 +1,13 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.143-beta.0
3
+ ## 2.0.143
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.143-beta.0
9
- - @akinon/theme-contract@2.0.143-beta.0
7
+ - 826c588: ZERO-5173: Add a product card list slider, image count, hover zoom and the listing's grid views, and show an unsaved collection pick's channel rows on the canvas
8
+ - Updated dependencies [826c588]
9
+ - @akinon/theme-contract@2.0.143
10
+ - @akinon/next@2.0.143
10
11
 
11
12
  ## 2.0.142
12
13
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.143-beta.0",
4
+ "version": "2.0.143",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.143-beta.0",
11
+ "@akinon/next": "2.0.143",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.143-beta.0",
16
+ "@akinon/theme-contract": "2.0.143",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.143-beta.0",
24
+ "@akinon/next": "2.0.143",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -6,6 +6,12 @@ import { defineCustomBlock, registerCustomBlocks } from '../custom';
6
6
  import { useThemeSectionData } from '../theme-section-data-context';
7
7
  import { __resetEditorBridgeForTests } from '../editor-bridge';
8
8
  import ThemePlaceholderWrapper from '../theme-placeholder-wrapper';
9
+ import {
10
+ collectionPreviewWidget,
11
+ collectionPreviewWidgetSlug,
12
+ fetchDesignerPreviewRows,
13
+ PREVIEW_ROWS_TIMEOUT_MS
14
+ } from '../utils/live-rows';
9
15
 
10
16
  /**
11
17
  * ZERO-5084 — the editor canvas has to show the rows the storefront shows.
@@ -444,9 +450,260 @@ describe('the editor canvas', () => {
444
450
  });
445
451
  });
446
452
 
453
+ /**
454
+ * The snapshot of an unsaved pick is Omnitron's item list — the catalogue's
455
+ * rows, out-of-stock variants and old prices included, in its own order — so
456
+ * it showed other products than the ones the section showed once published
457
+ * (seen on GANT: eight sold-out discounted shirts before publish, twelve
458
+ * OUTLET shorts after). The editor now keeps a hidden widget bound to the
459
+ * pick's binding and stamps its slug on the source; the canvas reads the
460
+ * pick's rows from it.
461
+ */
462
+ describe('an unsaved pick the editor prepared a preview widget for', () => {
463
+ beforeEach(frameDocument);
464
+
465
+ const PREVIEW = 'theme-editor-collection-2-6';
466
+ const PREPARED = {
467
+ pk: 2,
468
+ productLimit: 6,
469
+ products: NEW_PICK,
470
+ previewWidget: PREVIEW
471
+ };
472
+ /** What the channel serves for collection 2 at limit 6. */
473
+ const CHANNEL = [row(6101, 'Lacivert Sort'), row(6102, 'Krem Sort')];
474
+
475
+ it('shows the rows the channel will serve, and nothing else on the way', async () => {
476
+ const answer = deferred();
477
+ fetchMock.mockReturnValueOnce(answer.promise);
478
+
479
+ renderWrapper();
480
+ turnDesignerOn();
481
+ sendTheme(PREPARED);
482
+ // While the preview widget is asked, the section keeps what it showed —
483
+ // never the snapshot, which would be swapped out a moment later.
484
+ shows(namesOf(LIVE));
485
+ hides(namesOf(NEW_PICK));
486
+
487
+ expect(fetchMock).toHaveBeenCalledTimes(1);
488
+ expect(fetchMock.mock.calls[0][0]).toMatch(
489
+ new RegExp(`^/api/client/widgets/${PREVIEW}/\\?t=\\d+$`)
490
+ );
491
+
492
+ answer.resolve(
493
+ jsonResponse({ slug: PREVIEW, attributes: {}, products: CHANNEL })
494
+ );
495
+ await flush();
496
+
497
+ shows(namesOf(CHANNEL));
498
+ hides(namesOf(NEW_PICK));
499
+ hides(namesOf(LIVE));
500
+
501
+ // Once per binding: later updates reuse the rows.
502
+ sendTheme(PREPARED, {}, 'UPDATE_THEME');
503
+ await flush();
504
+ expect(fetchMock).toHaveBeenCalledTimes(1);
505
+ shows(namesOf(CHANNEL));
506
+ });
507
+
508
+ it('keeps them through the publish until the section’s own answer is in', async () => {
509
+ const sectionAnswer = deferred();
510
+ fetchMock
511
+ .mockResolvedValueOnce(jsonResponse({ products: CHANNEL }))
512
+ .mockReturnValueOnce(sectionAnswer.promise);
513
+
514
+ renderWrapper();
515
+ turnDesignerOn();
516
+ sendTheme(PREPARED);
517
+ await flush();
518
+ shows(namesOf(CHANNEL));
519
+
520
+ sendTheme(PREPARED, { dataSourceSaved: true }, 'UPDATE_THEME');
521
+ // No step back to the snapshot while the section's widget is asked.
522
+ shows(namesOf(CHANNEL));
523
+ hides(namesOf(NEW_PICK));
524
+ expect(fetchMock).toHaveBeenCalledTimes(2);
525
+ expect(fetchMock.mock.calls[1][0]).toMatch(
526
+ new RegExp(`^/api/client/widgets/${SECTION}/\\?t=\\d+$`)
527
+ );
528
+
529
+ sectionAnswer.resolve(jsonResponse({ products: REPOINTED_LIVE }));
530
+ await flush();
531
+ shows(namesOf(REPOINTED_LIVE));
532
+ });
533
+
534
+ it('asks for nothing when the stamp names another binding', () => {
535
+ renderWrapper();
536
+ turnDesignerOn();
537
+ // Re-pointed to another limit by something that prepared no widget: the
538
+ // stamped one answers for limit 6, not for this binding.
539
+ sendTheme({ ...PREPARED, productLimit: 12 });
540
+
541
+ shows(namesOf(NEW_PICK));
542
+ expect(fetchMock).not.toHaveBeenCalled();
543
+ });
544
+
545
+ it('falls back to the snapshot when the preview widget serves no products', async () => {
546
+ fetchMock.mockResolvedValueOnce(
547
+ jsonResponse({ slug: PREVIEW, attributes: {}, products: [] })
548
+ );
549
+
550
+ renderWrapper();
551
+ turnDesignerOn();
552
+ sendTheme(PREPARED);
553
+ await flush();
554
+
555
+ expect(fetchMock).toHaveBeenCalledTimes(1);
556
+ shows(namesOf(NEW_PICK));
557
+ });
558
+
559
+ it('goes from one prepared pick to the next without the snapshot in between', async () => {
560
+ const NEXT_PREVIEW = 'theme-editor-collection-3-6';
561
+ const NEXT_SNAPSHOT = [row(700, 'Next Pick Snapshot')];
562
+ const NEXT_CHANNEL = [row(7101, 'Bordo Kazak'), row(7102, 'Gri Kazak')];
563
+ const nextAnswer = deferred();
564
+ fetchMock
565
+ .mockResolvedValueOnce(jsonResponse({ products: CHANNEL }))
566
+ .mockReturnValueOnce(nextAnswer.promise);
567
+
568
+ renderWrapper();
569
+ turnDesignerOn();
570
+ sendTheme(PREPARED);
571
+ await flush();
572
+ shows(namesOf(CHANNEL));
573
+
574
+ sendTheme(
575
+ {
576
+ pk: 3,
577
+ productLimit: 6,
578
+ products: NEXT_SNAPSHOT,
579
+ previewWidget: NEXT_PREVIEW
580
+ },
581
+ {},
582
+ 'UPDATE_THEME'
583
+ );
584
+ shows(namesOf(CHANNEL));
585
+ hides(namesOf(NEXT_SNAPSHOT));
586
+ expect(fetchMock.mock.calls[1][0]).toMatch(
587
+ new RegExp(`^/api/client/widgets/${NEXT_PREVIEW}/\\?t=\\d+$`)
588
+ );
589
+
590
+ nextAnswer.resolve(jsonResponse({ products: NEXT_CHANNEL }));
591
+ await flush();
592
+ shows(namesOf(NEXT_CHANNEL));
593
+ hides(namesOf(CHANNEL));
594
+ });
595
+
596
+ it('never attaches a preview answer to a binding the section has left', async () => {
597
+ const lateAnswer = deferred();
598
+ fetchMock.mockReturnValueOnce(lateAnswer.promise);
599
+
600
+ renderWrapper();
601
+ turnDesignerOn();
602
+ sendTheme(PREPARED);
603
+
604
+ const THIRD_PICK = [row(700, 'Third Pick Snapshot')];
605
+ sendTheme(
606
+ { pk: 3, productLimit: 6, products: THIRD_PICK },
607
+ {},
608
+ 'UPDATE_THEME'
609
+ );
610
+
611
+ lateAnswer.resolve(jsonResponse({ products: CHANNEL }));
612
+ await flush();
613
+
614
+ shows(namesOf(THIRD_PICK));
615
+ hides(namesOf(CHANNEL));
616
+ });
617
+ });
618
+
619
+ describe('a preview widget that does not answer', () => {
620
+ afterEach(() => jest.useRealTimers());
621
+
622
+ it('is given up on after the timeout, so the section stops holding', async () => {
623
+ jest.useFakeTimers();
624
+ fetchMock.mockImplementation(
625
+ (_url: string, init?: { signal?: AbortSignal }) =>
626
+ new Promise((_resolve, reject) => {
627
+ init?.signal?.addEventListener('abort', () =>
628
+ reject(new Error('aborted'))
629
+ );
630
+ })
631
+ );
632
+
633
+ const answer = fetchDesignerPreviewRows('theme-editor-collection-2-6');
634
+ jest.advanceTimersByTime(PREVIEW_ROWS_TIMEOUT_MS);
635
+
636
+ await expect(answer).resolves.toBeNull();
637
+ });
638
+ });
639
+
640
+ describe('the preview widget a source names', () => {
641
+ const source = (collection: Record<string, unknown>) =>
642
+ collectionSource(collection);
643
+
644
+ it('is one per binding, named as the editor names it', () => {
645
+ expect(collectionPreviewWidgetSlug(1, 8)).toBe(
646
+ 'theme-editor-collection-1-8'
647
+ );
648
+ expect(collectionPreviewWidgetSlug('1572', '12')).toBe(
649
+ 'theme-editor-collection-1572-12'
650
+ );
651
+ expect(collectionPreviewWidgetSlug(0, 8)).toBeNull();
652
+ expect(collectionPreviewWidgetSlug(1, '8/../x')).toBeNull();
653
+ expect(collectionPreviewWidgetSlug(undefined, 8)).toBeNull();
654
+ });
655
+
656
+ it('is read only while the stamp names the current binding', () => {
657
+ const stamp = 'theme-editor-collection-1-8';
658
+
659
+ expect(
660
+ collectionPreviewWidget(
661
+ source({ pk: 1, productLimit: 8, previewWidget: stamp })
662
+ )
663
+ ).toBe(stamp);
664
+ expect(
665
+ collectionPreviewWidget(
666
+ source({ pk: 1, productLimit: 12, previewWidget: stamp })
667
+ )
668
+ ).toBeNull();
669
+ expect(
670
+ collectionPreviewWidget(source({ pk: 1, productLimit: 8 }))
671
+ ).toBeNull();
672
+ expect(
673
+ collectionPreviewWidget(
674
+ source({ pk: 1, productLimit: 8, previewWidget: '../widgets/x' })
675
+ )
676
+ ).toBeNull();
677
+ expect(collectionPreviewWidget(null)).toBeNull();
678
+ });
679
+ });
680
+
447
681
  describe('the storefront', () => {
448
682
  beforeEach(unframeDocument);
449
683
 
684
+ it('never asks for a preview widget', () => {
685
+ render(
686
+ <ThemePlaceholderWrapper
687
+ slug={SLOT}
688
+ initialSections={[
689
+ section(
690
+ collectionSource({
691
+ pk: 2,
692
+ productLimit: 6,
693
+ products: NEW_PICK,
694
+ previewWidget: 'theme-editor-collection-2-6'
695
+ })
696
+ )
697
+ ]}
698
+ initialPlaceholderId={SLOT}
699
+ isDesignMode={false}
700
+ />
701
+ );
702
+
703
+ shows(namesOf(NEW_PICK));
704
+ expect(fetchMock).not.toHaveBeenCalled();
705
+ });
706
+
450
707
  it('renders the server rows and asks for nothing', () => {
451
708
  const view = renderWrapper();
452
709
 
@@ -0,0 +1,102 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ import ThemeBlock, { type Block } from '../../theme-block';
5
+ import { buildIteratorBlock } from '../../utils/iterator-utils';
6
+
7
+ /**
8
+ * ZERO-5173 — the Product Card's favourite draws the product listing's heart:
9
+ * gant.co.uk's 8-unit glyph, a finer outline with rounded ends, at 8px. It is
10
+ * one more icon of the favourite block, on a grid of its own, and the icons
11
+ * that were already there keep the markup published cards render.
12
+ */
13
+ const LISTING_HEART =
14
+ 'M4.966 1.01c.581-.68 1.487-.68 2.068 0 .622.729.622 1.938 0 2.666L4 7.23.966 3.676c-.622-.728-.622-1.937 0-2.665.581-.682 1.487-.682 2.068 0l.586.686.38.446.38-.446z';
15
+
16
+ const favouriteIn = (properties: Record<string, unknown>) => {
17
+ const iterator: Block = {
18
+ id: 'it',
19
+ type: 'group',
20
+ label: 'Iterator',
21
+ isIterator: true,
22
+ properties: { tag: 'div', iteratorDataPath: 'products', iteratorCount: 1 },
23
+ styles: {},
24
+ order: 0,
25
+ hidden: false,
26
+ blocks: [
27
+ {
28
+ id: 'card',
29
+ type: 'group',
30
+ label: 'Product Card',
31
+ properties: { tag: 'div' },
32
+ styles: {},
33
+ order: 0,
34
+ hidden: false,
35
+ blocks: [
36
+ {
37
+ id: 'fav',
38
+ type: 'favorite-button',
39
+ label: 'Favourite',
40
+ properties: { showLabel: false, ...properties },
41
+ styles: {},
42
+ order: 0,
43
+ hidden: false,
44
+ blocks: []
45
+ }
46
+ ]
47
+ }
48
+ ]
49
+ };
50
+ const expanded = buildIteratorBlock({
51
+ iteratorBlock: iterator,
52
+ sectionDataSource: {
53
+ id: 'ds-1',
54
+ name: 'Seçtiklerimiz',
55
+ type: 'collection',
56
+ details: {
57
+ collection: {
58
+ pk: 1,
59
+ productLimit: 1,
60
+ products: [{ pk: 11, name: 'A', price: '10.00', absolute_url: '/a/' }]
61
+ }
62
+ }
63
+ },
64
+ pageContext: null
65
+ } as never);
66
+ const [clone] = expanded.blocks || [];
67
+
68
+ const { container } = render(
69
+ <ThemeBlock block={clone} placeholderId="home" sectionId="s1" />
70
+ );
71
+ return container.querySelector('[data-favorite-button] svg') as SVGElement;
72
+ };
73
+
74
+ describe('the favourite’s icons', () => {
75
+ it('draws the listing’s thin heart on its own 8-unit grid', () => {
76
+ const icon = favouriteIn({ icon: 'heart-thin', iconSize: 8 });
77
+
78
+ expect(icon.getAttribute('viewBox')).toBe('0 0 8 8');
79
+ expect(icon.getAttribute('width')).toBe('8');
80
+ expect(icon.getAttribute('stroke-width')).toBe('1');
81
+ expect(icon.getAttribute('stroke-linecap')).toBe('round');
82
+ expect(icon.getAttribute('fill')).toBe('none');
83
+ expect(icon.querySelector('path')?.getAttribute('d')).toBe(LISTING_HEART);
84
+ });
85
+
86
+ it('keeps the markup the other icons always had', () => {
87
+ const icon = favouriteIn({ icon: 'heart', iconSize: 14 });
88
+
89
+ expect(icon.getAttribute('viewBox')).toBe('0 0 24 24');
90
+ expect(icon.getAttribute('stroke-width')).toBe('1.5');
91
+ expect(icon.hasAttribute('stroke-linecap')).toBe(false);
92
+ expect(icon.querySelector('path')?.getAttribute('d')).toBe(
93
+ 'M12 20s-7-4.35-7-9a4 4 0 0 1 7-2.65A4 4 0 0 1 19 11c0 4.65-7 9-7 9z'
94
+ );
95
+ });
96
+
97
+ it('falls back to the heart for an icon it does not know', () => {
98
+ const icon = favouriteIn({ icon: 'unknown' });
99
+
100
+ expect(icon.getAttribute('viewBox')).toBe('0 0 24 24');
101
+ });
102
+ });
@@ -0,0 +1,47 @@
1
+ import { normalizeCollectionRow } from '../../utils/iterator-utils';
2
+ import { normalizeListingProduct } from '../listing-row';
3
+
4
+ /**
5
+ * ZERO-5173 — the price-history caption a product card prints under a
6
+ * discounted price, decided the way the GANT listing decides it: "Son 10
7
+ * günün en düşük fiyatı", unless commerce's price-history figure
8
+ * (`price_extra_field.previous_price`) is below the selling price, where a
9
+ * lowest-price claim would be false and the listing says "Avantajlı Fiyat".
10
+ */
11
+ const product = (price: string, retail: string, previous?: string) => ({
12
+ pk: 1,
13
+ name: 'GANT Erkek Sarı Regular Fit Logolu Polo Yaka T-Shirt',
14
+ absolute_url: '/polo/',
15
+ price,
16
+ retail_price: retail,
17
+ ...(previous !== undefined
18
+ ? { price_extra_field: { previous_price: previous } }
19
+ : {})
20
+ });
21
+
22
+ const noteOf = (price: string, retail: string, previous?: string) =>
23
+ normalizeListingProduct(product(price, retail, previous), 0, {})?.priceNote;
24
+
25
+ describe('the price note of a listing row', () => {
26
+ it('captions a discounted row only', () => {
27
+ expect(noteOf('5499.00', '5499.00')).toBeUndefined();
28
+ expect(noteOf('5499.00', '5499.00', '4399.00')).toBeUndefined();
29
+ expect(noteOf('4399.00', '5499.00')).toBe('lowest');
30
+ });
31
+
32
+ it('claims the lowest price unless the price history is below the price', () => {
33
+ // At or under the period's lowest: the claim holds.
34
+ expect(noteOf('5399.00', '8999.00', '7199.00')).toBe('lowest');
35
+ expect(noteOf('5399.00', '8999.00', '5399.00')).toBe('lowest');
36
+ // Above it: "advantageous", never "lowest".
37
+ expect(noteOf('5399.00', '8999.00', '4999.00')).toBe('advantageous');
38
+ });
39
+
40
+ it('reaches a collection row, which a Product Card repeats over', () => {
41
+ const row = normalizeCollectionRow(
42
+ product('5399.00', '8999.00', '4999.00'),
43
+ 0
44
+ );
45
+ expect(row.priceNote).toBe('advantageous');
46
+ });
47
+ });
@@ -24,15 +24,31 @@ import {
24
24
 
25
25
  const FAVORITES_PAGE_SIZE = 100;
26
26
 
27
- const ALLOWED_ICONS = ['heart', 'star', 'bookmark'];
27
+ const ALLOWED_ICONS = ['heart', 'heart-thin', 'star', 'bookmark'];
28
28
  const ALLOWED_LABEL_POSITIONS = ['before', 'after'];
29
29
 
30
30
  const ICON_PATHS: Record<string, string> = {
31
31
  heart: 'M12 20s-7-4.35-7-9a4 4 0 0 1 7-2.65A4 4 0 0 1 19 11c0 4.65-7 9-7 9z',
32
+ // The product listing's heart (gant.co.uk's): a finer outline with rounded
33
+ // ends, on an 8-unit grid of its own (ICON_GRIDS).
34
+ 'heart-thin':
35
+ 'M4.966 1.01c.581-.68 1.487-.68 2.068 0 .622.729.622 1.938 0 2.666L4 7.23.966 3.676c-.622-.728-.622-1.937 0-2.665.581-.682 1.487-.682 2.068 0l.586.686.38.446.38-.446z',
32
36
  star: 'M12 4l2.5 5.2 5.5.8-4 3.9 1 5.6-5-2.7-5 2.7 1-5.6-4-3.9 5.5-.8L12 4z',
33
37
  bookmark: 'M7 4h10a1 1 0 0 1 1 1v15l-6-3.5L6 20V5a1 1 0 0 1 1-1z'
34
38
  };
35
39
 
40
+ /**
41
+ * The grid an icon is drawn on where it is not the 24-unit one the others
42
+ * share: the thin heart is an 8-unit glyph whose 1-unit stroke is one pixel
43
+ * at its natural 8px, the size the listing draws it at.
44
+ */
45
+ const ICON_GRIDS: Record<
46
+ string,
47
+ { viewBox: string; strokeWidth: string; strokeLinecap: 'round' }
48
+ > = {
49
+ 'heart-thin': { viewBox: '0 0 8 8', strokeWidth: '1', strokeLinecap: 'round' }
50
+ };
51
+
36
52
  /**
37
53
  * Extra props land on the <svg> itself rather than on a wrapper: the icon is
38
54
  * the element a brand styles, and wrapping it would change the markup every
@@ -47,20 +63,25 @@ const FavoriteIcon = ({
47
63
  filled: boolean;
48
64
  name: string;
49
65
  size: number;
50
- } & React.SVGProps<SVGSVGElement>) => (
51
- <svg
52
- width={size}
53
- height={size}
54
- viewBox="0 0 24 24"
55
- fill={filled ? 'currentColor' : 'none'}
56
- stroke="currentColor"
57
- strokeWidth="1.5"
58
- aria-hidden="true"
59
- {...rest}
60
- >
61
- <path d={ICON_PATHS[name] || ICON_PATHS.heart} />
62
- </svg>
63
- );
66
+ } & React.SVGProps<SVGSVGElement>) => {
67
+ const grid = ICON_GRIDS[name];
68
+
69
+ return (
70
+ <svg
71
+ width={size}
72
+ height={size}
73
+ viewBox={grid?.viewBox ?? '0 0 24 24'}
74
+ fill={filled ? 'currentColor' : 'none'}
75
+ stroke="currentColor"
76
+ strokeWidth={grid?.strokeWidth ?? '1.5'}
77
+ strokeLinecap={grid?.strokeLinecap}
78
+ aria-hidden="true"
79
+ {...rest}
80
+ >
81
+ <path d={ICON_PATHS[name] || ICON_PATHS.heart} />
82
+ </svg>
83
+ );
84
+ };
64
85
 
65
86
  /**
66
87
  * Functional commerce block: favourite (wishlist) toggle for the ambient
@@ -11,9 +11,12 @@ import {
11
11
  emitCommerceEvent,
12
12
  readItemsCurrency
13
13
  } from '../utils/analytics';
14
+ import { postToEditor } from '../editor-bridge';
14
15
  import { BlockRendererProps } from './block-renderer-registry';
15
16
  import { DesignerPlaceholder, useThemeItemScope } from './product-block-utils';
16
17
  import { renderComposedChildren } from './render-composed-children';
18
+ import { CardListSlider, readListGap } from '../components/card-list-slider';
19
+ import { CardListViews } from '../components/card-list-views';
17
20
 
18
21
  /**
19
22
  * Composed anchors to the storefront's own routes navigate client-side. A
@@ -158,7 +161,7 @@ const GroupBlock = ({
158
161
  );
159
162
  }
160
163
 
161
- const renderedChildren = renderComposedChildren({
164
+ const composedChildren = renderComposedChildren({
162
165
  blocks: block.blocks,
163
166
  placeholderId,
164
167
  sectionId,
@@ -167,6 +170,54 @@ const GroupBlock = ({
167
170
  currentBreakpoint
168
171
  });
169
172
 
173
+ // ZERO-5173: the repeat holding a Product Cards section's cards, with the
174
+ // section's slider on (the property-switch pass wrote `cardsSlider`, the
175
+ // section's slider title among it). The same cards go into one sliding row
176
+ // instead of the repeat's grid; the row keeps the gap the repeat was
177
+ // authored with.
178
+ const renderedChildren = block.cardsSlider ? (
179
+ <CardListSlider
180
+ sliderId={`group-${String(block.id).replace(/[^a-zA-Z0-9_-]/g, '')}`}
181
+ settings={block.cardsSlider}
182
+ gap={readListGap(block.styles?.gap)}
183
+ defaultGap="0px"
184
+ isDesigner={isDesigner}
185
+ currentBreakpoint={currentBreakpoint}
186
+ // Its title is the section's setting: a click selects the section.
187
+ onTitleSelect={() =>
188
+ postToEditor({
189
+ type: 'SELECT_SECTION',
190
+ data: { placeholderId, sectionId }
191
+ })
192
+ }
193
+ >
194
+ {composedChildren}
195
+ </CardListSlider>
196
+ ) : block.cardsViews ? (
197
+ // The listing's grid views (the same pass wrote `cardsViews` for a repeat
198
+ // that is not sliding): the view overrides the repeat's column template by
199
+ // stylesheet — the cards are untouched — and the toggles, when the shopper
200
+ // is offered them, head its grid.
201
+ <>
202
+ <CardListViews
203
+ scopeId={String(block.styleSourceId || block.id)}
204
+ settings={block.cardsViews}
205
+ isDesigner={isDesigner}
206
+ currentBreakpoint={currentBreakpoint}
207
+ // The views are the section's settings: a click selects the section.
208
+ onSelect={() =>
209
+ postToEditor({
210
+ type: 'SELECT_SECTION',
211
+ data: { placeholderId, sectionId }
212
+ })
213
+ }
214
+ />
215
+ {composedChildren}
216
+ </>
217
+ ) : (
218
+ composedChildren
219
+ );
220
+
170
221
  if (useRouterLink) {
171
222
  return (
172
223
  <NextLink
@@ -19,6 +19,7 @@ import {
19
19
  useValueLocale
20
20
  } from '../utils/localized-value';
21
21
  import { BlockRendererProps } from './block-renderer-registry';
22
+ import { CardZoom } from '../components/card-zoom';
22
23
 
23
24
  /** "No breakpoint answered", which '' cannot express (see `pickImageSource`). */
24
25
  const NO_RESPONSIVE_VALUE = Symbol('no-responsive-value');
@@ -364,6 +365,29 @@ const ImageBlock = ({
364
365
  />
365
366
  );
366
367
 
368
+ /*
369
+ * The card's picture zooming in on hover (ZERO-5173): the property-switch
370
+ * pass wrote `cardZoom` because the card or the list around it asked for
371
+ * it. The frame takes the box the picture had and clips the enlarged one,
372
+ * so the card around it does not move; without the setting the element is
373
+ * the one this block always emitted.
374
+ */
375
+ const withZoom = (picture: React.ReactElement): React.ReactElement =>
376
+ block.cardZoom && src ? (
377
+ <CardZoom
378
+ style={{
379
+ width: boxWidth,
380
+ height: boxHeight,
381
+ borderRadius: borderRadius ?? '0px',
382
+ aspectRatio: boxAspectRatio
383
+ }}
384
+ >
385
+ {picture}
386
+ </CardZoom>
387
+ ) : (
388
+ picture
389
+ );
390
+
367
391
  /*
368
392
  * No override, or the canvas: emit exactly what this block emitted before
369
393
  * this feature existed. Every block published to date takes this path, so its
@@ -372,7 +396,7 @@ const ImageBlock = ({
372
396
  * snapshot and every brand's CSS that targets `[data-block-id] > img`.
373
397
  */
374
398
  if (!src || isDesigner || pictureSources.length === 0) {
375
- return content;
399
+ return withZoom(content);
376
400
  }
377
401
 
378
402
  /*
@@ -387,7 +411,7 @@ const ImageBlock = ({
387
411
  * keeping the element, which is exactly what is wanted: the <source> list is
388
412
  * markup, not layout.
389
413
  */
390
- return (
414
+ return withZoom(
391
415
  <picture style={{ display: 'contents' }}>
392
416
  {pictureSources.map((source) => (
393
417
  <source