@akinon/pz-theme 2.0.143-rc.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 +8 -35
- package/package.json +4 -4
- package/src/__tests__/designer-live-rows.test.tsx +257 -0
- package/src/blocks/__tests__/favorite-button-icons.test.tsx +102 -0
- package/src/blocks/__tests__/listing-price-note.test.ts +47 -0
- package/src/blocks/favorite-button-block.tsx +36 -15
- package/src/blocks/group-block.tsx +52 -1
- package/src/blocks/image-block.tsx +26 -2
- package/src/blocks/listing-context.ts +5 -2
- package/src/blocks/listing-row.ts +64 -4
- package/src/blocks/product-grid-block.tsx +83 -3
- package/src/components/card-list-slider.tsx +374 -0
- package/src/components/card-list-views.tsx +278 -0
- package/src/components/card-zoom.tsx +53 -0
- package/src/components/with-designer-features.tsx +18 -0
- package/src/theme-block.tsx +11 -0
- package/src/theme-placeholder-wrapper.tsx +52 -7
- package/src/utils/__tests__/card-media.test.tsx +789 -0
- package/src/utils/__tests__/card-views.test.tsx +332 -0
- package/src/utils/__tests__/swatch-colors.test.ts +145 -0
- package/src/utils/card-media.ts +331 -0
- package/src/utils/iterator-utils.ts +10 -3
- package/src/utils/live-rows.ts +169 -34
- package/src/utils/property-switches.ts +248 -2
- package/src/utils/swatch-colors.ts +81 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,40 +1,13 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.143
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
- Updated dependencies [
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
- Updated dependencies
|
|
12
|
-
- Updated dependencies [2fea41430]
|
|
13
|
-
- Updated dependencies [b55acb768]
|
|
14
|
-
- Updated dependencies [760258c1c]
|
|
15
|
-
- Updated dependencies [143be2b9d]
|
|
16
|
-
- Updated dependencies [7889b08fe]
|
|
17
|
-
- Updated dependencies [9f8cd3bc5]
|
|
18
|
-
- Updated dependencies [bfafa3f49]
|
|
19
|
-
- Updated dependencies [57d7eb305]
|
|
20
|
-
- Updated dependencies [d99a6a7d5]
|
|
21
|
-
- Updated dependencies [9db81a714]
|
|
22
|
-
- Updated dependencies [591e345e1]
|
|
23
|
-
- Updated dependencies [4de5303c5]
|
|
24
|
-
- Updated dependencies [95b139dc1]
|
|
25
|
-
- Updated dependencies [1d00f2d06]
|
|
26
|
-
- Updated dependencies [4ac7b2a1e]
|
|
27
|
-
- Updated dependencies [e9598c71d]
|
|
28
|
-
- Updated dependencies [4998a9631]
|
|
29
|
-
- Updated dependencies [804d2bd6c]
|
|
30
|
-
- Updated dependencies [3909d3224]
|
|
31
|
-
- Updated dependencies [6a3d8a631]
|
|
32
|
-
- Updated dependencies [e18836b20]
|
|
33
|
-
- Updated dependencies [b1111d7b1]
|
|
34
|
-
- Updated dependencies [1595d458e]
|
|
35
|
-
- Updated dependencies [dca128112]
|
|
36
|
-
- @akinon/next@2.0.143-rc.0
|
|
37
|
-
- @akinon/theme-contract@2.0.143-rc.0
|
|
3
|
+
## 2.0.143
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
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
|
|
38
11
|
|
|
39
12
|
## 2.0.142
|
|
40
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|