@akinon/pz-theme 2.0.30-rc.0 → 2.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +9 -29
  2. package/jest/__mocks__/akinon-next-hooks.js +15 -0
  3. package/jest/__mocks__/akinon-next-image.js +22 -0
  4. package/jest/__mocks__/akinon-next-loader-spinner.js +7 -0
  5. package/jest/__mocks__/akinon-next-localization.js +10 -0
  6. package/jest/__mocks__/akinon-next-modal.js +11 -0
  7. package/jest/__mocks__/akinon-next-price.js +9 -0
  8. package/jest/__mocks__/akinon-next-rtk.js +30 -0
  9. package/jest/__mocks__/akinon-next-utils.js +8 -0
  10. package/jest/__mocks__/next-link.js +11 -0
  11. package/jest/__mocks__/settings.js +13 -0
  12. package/jest/setup.ts +33 -0
  13. package/jest.config.js +24 -3
  14. package/package.json +6 -3
  15. package/src/__tests__/theme-block.test.tsx +46 -0
  16. package/src/blocks/__tests__/accordion-block.test.tsx +178 -0
  17. package/src/blocks/__tests__/button-block.test.tsx +99 -0
  18. package/src/blocks/__tests__/counter-block.test.tsx +57 -0
  19. package/src/blocks/__tests__/embed-block.test.tsx +89 -0
  20. package/src/blocks/__tests__/group-block.test.tsx +175 -0
  21. package/src/blocks/__tests__/hotspot-block.test.tsx +93 -0
  22. package/src/blocks/__tests__/icon-block.test.tsx +88 -0
  23. package/src/blocks/__tests__/image-block.test.tsx +139 -0
  24. package/src/blocks/__tests__/image-gallery-block.test.tsx +117 -0
  25. package/src/blocks/__tests__/link-block.test.tsx +184 -0
  26. package/src/blocks/__tests__/map-block.test.tsx +165 -0
  27. package/src/blocks/__tests__/slider-block.test.tsx +148 -0
  28. package/src/blocks/__tests__/text-block.test.tsx +87 -0
  29. package/src/blocks/__tests__/trivial-blocks.test.tsx +59 -0
  30. package/src/blocks/__tests__/video-block.test.tsx +196 -0
  31. package/src/blocks/counter-block.tsx +1 -1
  32. package/src/blocks/group-block.tsx +1 -1
  33. package/src/blocks/icon-block.tsx +11 -6
  34. package/src/blocks/video-block.tsx +1 -1
  35. package/src/sections/__tests__/before-after-section.test.tsx +139 -0
  36. package/src/sections/__tests__/countdown-campaign-banner-section.test.tsx +188 -0
  37. package/src/sections/__tests__/coupon-banner-section.test.tsx +188 -0
  38. package/src/sections/__tests__/divider-section.test.tsx +161 -0
  39. package/src/sections/__tests__/hover-showcase-section.test.tsx +193 -0
  40. package/src/sections/__tests__/image-hotspot-section.test.tsx +110 -0
  41. package/src/sections/__tests__/notification-banner-section.test.tsx +144 -0
  42. package/src/sections/__tests__/section-wrapper.test.tsx +164 -0
  43. package/src/sections/__tests__/stats-counter-section.test.tsx +245 -0
  44. package/src/sections/__tests__/tabs-section.test.tsx +115 -0
  45. package/src/sections/coupon-banner-section.tsx +1 -4
  46. package/src/theme-block.tsx +3 -1
@@ -0,0 +1,59 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import DividerBlock from '../divider-block';
4
+ import TabBlock from '../tab-block';
5
+
6
+ const props = (over: Record<string, unknown> = {}) =>
7
+ ({
8
+ block: {
9
+ id: 'd',
10
+ type: 'divider',
11
+ label: 'd',
12
+ styles: {},
13
+ properties: {},
14
+ order: 0,
15
+ hidden: false
16
+ },
17
+ placeholderId: 'p',
18
+ sectionId: 's',
19
+ isDesigner: false,
20
+ ...over
21
+ } as any);
22
+
23
+ describe('DividerBlock', () => {
24
+ it('renders a span with the dangerous defaults when no styles are set', () => {
25
+ const { container } = render(<DividerBlock {...props()} />);
26
+ const span = container.querySelector('span');
27
+ expect(span).not.toBeNull();
28
+ expect(span!.style.display).toBe('block');
29
+ expect(span!.style.height).toBe('1px');
30
+ expect(span!.style.width).toBe('100%');
31
+ expect(span!.getAttribute('style')).toMatch(/e0e0e0|224, 224, 224/i);
32
+ });
33
+
34
+ it('resolves a responsive height for the active breakpoint', () => {
35
+ const { container } = render(
36
+ <DividerBlock
37
+ {...props({
38
+ block: {
39
+ id: 'd',
40
+ type: 'divider',
41
+ styles: { height: { desktop: '4px', mobile: '2px' } },
42
+ properties: {},
43
+ order: 0,
44
+ hidden: false
45
+ },
46
+ currentBreakpoint: 'mobile'
47
+ })}
48
+ />
49
+ );
50
+ expect(container.querySelector('span')!.style.height).toBe('2px');
51
+ });
52
+ });
53
+
54
+ describe('TabBlock', () => {
55
+ it('renders nothing (panes are rendered by TabsSection)', () => {
56
+ const { container } = render(<TabBlock {...props()} />);
57
+ expect(container.innerHTML).toBe('');
58
+ });
59
+ });
@@ -0,0 +1,196 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import VideoBlock from '../video-block';
4
+
5
+ const renderBlock = (value: any, properties?: any) =>
6
+ render(<VideoBlock block={{ value, properties } as any} />);
7
+
8
+ describe('VideoBlock', () => {
9
+ describe('YouTube URLs -> iframe', () => {
10
+ it('renders an iframe for a youtube.com watch URL', () => {
11
+ const { container } = renderBlock(
12
+ 'https://www.youtube.com/watch?v=abc123'
13
+ );
14
+ const iframe = container.querySelector('iframe');
15
+ expect(iframe).toBeInTheDocument();
16
+ expect(iframe).toHaveAttribute(
17
+ 'src',
18
+ 'https://www.youtube.com/embed/abc123'
19
+ );
20
+ });
21
+
22
+ it('renders an iframe for a youtu.be short URL', () => {
23
+ const { container } = renderBlock('https://youtu.be/xyz789?t=10');
24
+ const iframe = container.querySelector('iframe');
25
+ expect(iframe).toHaveAttribute(
26
+ 'src',
27
+ 'https://www.youtube.com/embed/xyz789'
28
+ );
29
+ });
30
+
31
+ it('renders an iframe for an embed URL', () => {
32
+ const { container } = renderBlock(
33
+ 'https://www.youtube.com/embed/embedId?foo=bar'
34
+ );
35
+ const iframe = container.querySelector('iframe');
36
+ expect(iframe).toHaveAttribute(
37
+ 'src',
38
+ 'https://www.youtube.com/embed/embedId'
39
+ );
40
+ });
41
+
42
+ it('falls back to placeholder when youtube videoId cannot be parsed', () => {
43
+ // matches "youtube.com" but no watch?v=/youtu.be/embed pattern -> videoId '' -> null
44
+ renderBlock('https://www.youtube.com/');
45
+ expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
46
+ });
47
+ });
48
+
49
+ describe('Vimeo URLs -> iframe', () => {
50
+ it('renders an iframe for a vimeo URL', () => {
51
+ const { container } = renderBlock('https://vimeo.com/123456789?h=abc');
52
+ const iframe = container.querySelector('iframe');
53
+ expect(iframe).toHaveAttribute(
54
+ 'src',
55
+ 'https://player.vimeo.com/video/123456789'
56
+ );
57
+ });
58
+ });
59
+
60
+ describe('Direct video file -> <video>', () => {
61
+ it('renders a <video> for an .mp4 URL', () => {
62
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4');
63
+ const video = container.querySelector('video');
64
+ expect(video).toBeInTheDocument();
65
+ expect(video).toHaveAttribute('src', 'https://cdn.example.com/clip.mp4');
66
+ });
67
+
68
+ it('renders a <video> for a .webm URL', () => {
69
+ const { container } = renderBlock('https://cdn.example.com/clip.webm');
70
+ expect(container.querySelector('video')).toBeInTheDocument();
71
+ });
72
+
73
+ it('renders a <video> for an .ogg URL (case-insensitive .OGG)', () => {
74
+ const { container } = renderBlock('https://cdn.example.com/clip.OGG');
75
+ expect(container.querySelector('video')).toBeInTheDocument();
76
+ });
77
+ });
78
+
79
+ describe('Invalid / empty -> placeholder', () => {
80
+ it('shows "No video URL provided" for empty string', () => {
81
+ const { container } = renderBlock('');
82
+ expect(screen.getByText('No video URL provided')).toBeInTheDocument();
83
+ expect(container.querySelector('iframe')).toBeNull();
84
+ expect(container.querySelector('video')).toBeNull();
85
+ });
86
+
87
+ it('shows "Invalid video URL" for an unrecognized URL', () => {
88
+ renderBlock('https://example.com/not-a-video');
89
+ expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
90
+ });
91
+ });
92
+
93
+ describe('YouTube property flags', () => {
94
+ it('appends autoplay=1', () => {
95
+ const { container } = renderBlock(
96
+ 'https://www.youtube.com/watch?v=abc123',
97
+ { autoplay: true }
98
+ );
99
+ const iframe = container.querySelector('iframe');
100
+ expect(iframe?.getAttribute('src')).toContain('autoplay=1');
101
+ });
102
+
103
+ it('appends loop=1 and playlist=<videoId>', () => {
104
+ const { container } = renderBlock(
105
+ 'https://www.youtube.com/watch?v=abc123',
106
+ { loop: true }
107
+ );
108
+ const src = container.querySelector('iframe')?.getAttribute('src') || '';
109
+ expect(src).toContain('loop=1');
110
+ expect(src).toContain('playlist=abc123');
111
+ });
112
+
113
+ it('appends mute=1 when muted', () => {
114
+ const { container } = renderBlock(
115
+ 'https://www.youtube.com/watch?v=abc123',
116
+ { muted: true }
117
+ );
118
+ expect(
119
+ container.querySelector('iframe')?.getAttribute('src')
120
+ ).toContain('mute=1');
121
+ });
122
+
123
+ it('appends controls=0 only when controls === false', () => {
124
+ const { container } = renderBlock(
125
+ 'https://www.youtube.com/watch?v=abc123',
126
+ { controls: false }
127
+ );
128
+ expect(
129
+ container.querySelector('iframe')?.getAttribute('src')
130
+ ).toContain('controls=0');
131
+ });
132
+
133
+ it('does not append controls when controls is true', () => {
134
+ const { container } = renderBlock(
135
+ 'https://www.youtube.com/watch?v=abc123',
136
+ { controls: true }
137
+ );
138
+ expect(
139
+ container.querySelector('iframe')?.getAttribute('src')
140
+ ).not.toContain('controls');
141
+ });
142
+ });
143
+
144
+ describe('Vimeo property flags', () => {
145
+ it('appends autoplay=1, loop=1, muted=1, controls=0', () => {
146
+ const { container } = renderBlock('https://vimeo.com/123456789', {
147
+ autoplay: true,
148
+ loop: true,
149
+ muted: true,
150
+ controls: false
151
+ });
152
+ const src = container.querySelector('iframe')?.getAttribute('src') || '';
153
+ expect(src).toContain('autoplay=1');
154
+ expect(src).toContain('loop=1');
155
+ expect(src).toContain('muted=1');
156
+ expect(src).toContain('controls=0');
157
+ });
158
+ });
159
+
160
+ describe('<video> flags', () => {
161
+ it('defaults controls to true (controls !== false)', () => {
162
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4');
163
+ const video = container.querySelector('video') as HTMLVideoElement;
164
+ expect(video.controls).toBe(true);
165
+ });
166
+
167
+ it('disables controls when controls === false', () => {
168
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
169
+ controls: false
170
+ });
171
+ const video = container.querySelector('video') as HTMLVideoElement;
172
+ expect(video.controls).toBe(false);
173
+ });
174
+
175
+ it('reflects autoplay / loop / muted flags', () => {
176
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
177
+ autoplay: true,
178
+ loop: true,
179
+ muted: true
180
+ });
181
+ const video = container.querySelector('video') as HTMLVideoElement;
182
+ expect(video.autoplay).toBe(true);
183
+ expect(video.loop).toBe(true);
184
+ // muted is set via the `muted` prop; React reflects it on the DOM property
185
+ expect(video).toHaveAttribute('loop');
186
+ expect(video).toHaveAttribute('autoplay');
187
+ });
188
+ });
189
+
190
+ describe('non-string value (BUG-4 fix)', () => {
191
+ it('renders the invalid placeholder instead of crashing on a non-string value', () => {
192
+ renderBlock(123 as any);
193
+ expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
194
+ });
195
+ });
196
+ });
@@ -281,7 +281,7 @@ const CounterBlock = ({
281
281
 
282
282
  const renderedBlocks = cloneAndInjectCounterValues(
283
283
  block.blocks
284
- .filter((b) => !b.hidden)
284
+ .filter((b) => (isDesigner ? true : !b.hidden))
285
285
  .sort((a, b) => (a.order || 0) - (b.order || 0)),
286
286
  values
287
287
  );
@@ -11,7 +11,7 @@ const GroupBlock = ({
11
11
  selectedBlockId,
12
12
  currentBreakpoint = 'desktop'
13
13
  }: BlockRendererProps) => {
14
- const tag = getResponsiveValue(block.properties?.tag, 'desktop', 'div');
14
+ const tag = getResponsiveValue(block.properties?.tag, currentBreakpoint, 'div');
15
15
  const Tag = tag as keyof React.JSX.IntrinsicElements;
16
16
  const href = tag === 'a' ? block.properties?.href : undefined;
17
17
 
@@ -15,14 +15,19 @@ const isValidUrlValue = (value: string): boolean => {
15
15
  }
16
16
  };
17
17
 
18
- const IconBlock = ({ block }: BlockRendererProps) => {
19
- const iconValue = getResponsiveValue(block.value, 'desktop', '');
20
- const iconSize = getResponsiveValue(block.styles?.size, 'desktop', '24') as
21
- | string
22
- | number;
18
+ const IconBlock = ({
19
+ block,
20
+ currentBreakpoint = 'desktop'
21
+ }: BlockRendererProps) => {
22
+ const iconValue = getResponsiveValue(block.value, currentBreakpoint, '');
23
+ const iconSize = getResponsiveValue(
24
+ block.styles?.size,
25
+ currentBreakpoint,
26
+ '24'
27
+ ) as string | number;
23
28
  const iconColor = getResponsiveValue(
24
29
  block.styles?.color,
25
- 'desktop',
30
+ currentBreakpoint,
26
31
  'currentColor'
27
32
  ) as string;
28
33
 
@@ -10,7 +10,7 @@ const getVideoEmbedUrl = (
10
10
  controls?: boolean;
11
11
  }
12
12
  ) => {
13
- if (!url) return null;
13
+ if (!url || typeof url !== 'string') return null;
14
14
 
15
15
  if (url.includes('youtube.com') || url.includes('youtu.be')) {
16
16
  let videoId = '';
@@ -0,0 +1,139 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ // ThemeBlock is a DEFAULT export rendered for the matched before/after blocks.
5
+ // Mock it so we can assert which block (by id) gets rendered.
6
+ jest.mock('../../theme-block', () => ({
7
+ __esModule: true,
8
+ default: ({ block }: { block: { id: string } }) => (
9
+ <div data-testid="tb" data-id={block.id} />
10
+ )
11
+ }));
12
+
13
+ import BeforeAfterSection from '../before-after-section';
14
+
15
+ const makeSection = (over: Record<string, unknown> = {}) =>
16
+ ({
17
+ id: 'sec-1',
18
+ blocks: [],
19
+ styles: {},
20
+ properties: {},
21
+ ...over
22
+ } as any);
23
+
24
+ const beforeImageBlock = { id: 'before-1', label: 'Before Image', type: 'image' };
25
+ const afterImageBlock = { id: 'after-1', label: 'After Image', type: 'image' };
26
+
27
+ describe('BeforeAfterSection — image by label', () => {
28
+ it('renders the before/after ThemeBlocks matched by label', () => {
29
+ const { getAllByTestId, queryByText } = render(
30
+ <BeforeAfterSection
31
+ section={makeSection({ blocks: [beforeImageBlock, afterImageBlock] })}
32
+ />
33
+ );
34
+
35
+ const ids = getAllByTestId('tb').map((el) => el.getAttribute('data-id'));
36
+ expect(ids).toEqual(expect.arrayContaining(['before-1', 'after-1']));
37
+ expect(ids).toHaveLength(2);
38
+
39
+ // No placeholders when both blocks are present.
40
+ expect(queryByText('No Before Image')).toBeNull();
41
+ expect(queryByText('No After Image')).toBeNull();
42
+ });
43
+
44
+ it('only matches blocks with the exact "Before Image" / "After Image" labels', () => {
45
+ const { queryAllByTestId, getByText } = render(
46
+ <BeforeAfterSection
47
+ section={makeSection({
48
+ blocks: [{ id: 'wrong-1', label: 'Some Other Image', type: 'image' }]
49
+ })}
50
+ />
51
+ );
52
+
53
+ // Non-matching label -> no ThemeBlock, both placeholders shown.
54
+ expect(queryAllByTestId('tb')).toHaveLength(0);
55
+ expect(getByText('No Before Image')).toBeInTheDocument();
56
+ expect(getByText('No After Image')).toBeInTheDocument();
57
+ });
58
+ });
59
+
60
+ describe('BeforeAfterSection — placeholder fallback', () => {
61
+ it('shows "No After Image" when after block is missing', () => {
62
+ const { getByText, queryByText } = render(
63
+ <BeforeAfterSection section={makeSection({ blocks: [beforeImageBlock] })} />
64
+ );
65
+ expect(getByText('No After Image')).toBeInTheDocument();
66
+ expect(queryByText('No Before Image')).toBeNull();
67
+ });
68
+
69
+ it('shows "No Before Image" when before block is missing', () => {
70
+ const { getByText, queryByText } = render(
71
+ <BeforeAfterSection section={makeSection({ blocks: [afterImageBlock] })} />
72
+ );
73
+ expect(getByText('No Before Image')).toBeInTheDocument();
74
+ expect(queryByText('No After Image')).toBeNull();
75
+ });
76
+
77
+ it('shows both placeholders with no blocks at all', () => {
78
+ const { getByText } = render(
79
+ <BeforeAfterSection section={makeSection({ blocks: [] })} />
80
+ );
81
+ expect(getByText('No Before Image')).toBeInTheDocument();
82
+ expect(getByText('No After Image')).toBeInTheDocument();
83
+ });
84
+ });
85
+
86
+ describe('BeforeAfterSection — slider default position', () => {
87
+ it('defaults the slider/clip to 50%', () => {
88
+ const { container } = render(
89
+ <BeforeAfterSection section={makeSection()} />
90
+ );
91
+
92
+ // The clip layer width and the divider handle left both derive from
93
+ // the default sliderPosition state (50).
94
+ const clip = container.querySelector('.border-r') as HTMLElement;
95
+ expect(clip).not.toBeNull();
96
+ expect(clip.style.width).toBe('50%');
97
+
98
+ const handle = container.querySelector('.cursor-ew-resize') as HTMLElement;
99
+ expect(handle).not.toBeNull();
100
+ expect(handle.style.left).toBe('50%');
101
+ });
102
+ });
103
+
104
+ describe('BeforeAfterSection — max-width handling', () => {
105
+ it('uses w-full and no inline maxWidth for max-width "full"', () => {
106
+ const { container } = render(
107
+ <BeforeAfterSection
108
+ section={makeSection({ styles: { 'max-width': 'full' } })}
109
+ />
110
+ );
111
+ const root = container.firstChild as HTMLElement;
112
+ expect(root.className).toContain('w-full');
113
+ expect(root.className).not.toContain('max-w-7xl');
114
+ expect(root.className).not.toContain('max-w-4xl');
115
+ // hasMaxWidth is false for "full" -> no inline maxWidth.
116
+ expect(root.style.maxWidth).toBe('');
117
+ });
118
+
119
+ it('sets inline maxWidth "none" for max-width "none"', () => {
120
+ const { container } = render(
121
+ <BeforeAfterSection
122
+ section={makeSection({ styles: { 'max-width': 'none' } })}
123
+ />
124
+ );
125
+ const root = container.firstChild as HTMLElement;
126
+ expect(root.style.maxWidth).toBe('none');
127
+ expect(root.className).not.toContain('max-w-7xl');
128
+ expect(root.className).not.toContain('w-full');
129
+ });
130
+
131
+ it('defaults to the "normal" max-width class (max-w-7xl)', () => {
132
+ const { container } = render(
133
+ <BeforeAfterSection section={makeSection()} />
134
+ );
135
+ const root = container.firstChild as HTMLElement;
136
+ expect(root.className).toContain('max-w-7xl');
137
+ expect(root.style.maxWidth).toBe('');
138
+ });
139
+ });
@@ -0,0 +1,188 @@
1
+ import React from 'react';
2
+ import { render, screen, act } from '@testing-library/react';
3
+
4
+ // Mock ThemeBlock so we exercise only this section's logic.
5
+ jest.mock('../../theme-block', () => ({
6
+ __esModule: true,
7
+ default: ({ block }: { block: { id: string } }) => (
8
+ <div data-testid="tb" data-id={block.id} />
9
+ )
10
+ }));
11
+
12
+ import CountdownCampaignBannerSection from '../countdown-campaign-banner-section';
13
+
14
+ // Minimal section factory matching the Section shape used by the component.
15
+ const makeSection = (overrides: Partial<any> = {}) => ({
16
+ id: 'sec-1',
17
+ type: 'countdown-campaign-banner',
18
+ properties: {},
19
+ styles: {},
20
+ blocks: [],
21
+ ...overrides
22
+ });
23
+
24
+ const renderSection = (props: any = {}) => {
25
+ let utils: ReturnType<typeof render>;
26
+ act(() => {
27
+ utils = render(
28
+ <CountdownCampaignBannerSection section={makeSection(props.section)} {...props} />
29
+ );
30
+ });
31
+ // @ts-expect-error assigned inside act
32
+ return utils;
33
+ };
34
+
35
+ // Fixed "now" so countdown math is deterministic.
36
+ const NOW = new Date('2026-06-26T00:00:00.000Z').getTime();
37
+ // now + 1d 2h 3m 4s
38
+ const DELTA_MS = ((((1 * 24 + 2) * 60) + 3) * 60 + 4) * 1000;
39
+ const END_MS = NOW + DELTA_MS;
40
+
41
+ beforeEach(() => {
42
+ jest.useFakeTimers();
43
+ jest.setSystemTime(NOW);
44
+ });
45
+
46
+ afterEach(() => {
47
+ jest.runOnlyPendingTimers();
48
+ jest.useRealTimers();
49
+ });
50
+
51
+ describe('CountdownCampaignBannerSection - parseEndDate', () => {
52
+ it('parses a millisecond epoch number into the correct remaining time', () => {
53
+ renderSection({ section: { properties: { 'end-date': END_MS } } });
54
+
55
+ expect(screen.queryByText('Campaign ended')).not.toBeInTheDocument();
56
+ expect(screen.getByText('01')).toBeInTheDocument(); // days
57
+ expect(screen.getByText('02')).toBeInTheDocument(); // hours
58
+ expect(screen.getByText('03')).toBeInTheDocument(); // minutes
59
+ expect(screen.getByText('04')).toBeInTheDocument(); // seconds
60
+ });
61
+
62
+ it('parses a second epoch number (< 10_000_000_000) the same as the ms form', () => {
63
+ const endSeconds = Math.floor(END_MS / 1000);
64
+ expect(endSeconds).toBeLessThan(10_000_000_000);
65
+
66
+ renderSection({ section: { properties: { 'end-date': endSeconds } } });
67
+
68
+ expect(screen.getByText('01')).toBeInTheDocument();
69
+ expect(screen.getByText('02')).toBeInTheDocument();
70
+ expect(screen.getByText('03')).toBeInTheDocument();
71
+ expect(screen.getByText('04')).toBeInTheDocument();
72
+ });
73
+
74
+ it('parses an ISO date string', () => {
75
+ const iso = new Date(END_MS).toISOString();
76
+ renderSection({ section: { properties: { 'end-date': iso } } });
77
+
78
+ expect(screen.getByText('01')).toBeInTheDocument();
79
+ expect(screen.getByText('02')).toBeInTheDocument();
80
+ expect(screen.getByText('03')).toBeInTheDocument();
81
+ expect(screen.getByText('04')).toBeInTheDocument();
82
+ });
83
+
84
+ it('parses a numeric string as a second epoch', () => {
85
+ const endSeconds = String(Math.floor(END_MS / 1000));
86
+ renderSection({ section: { properties: { 'end-date': endSeconds } } });
87
+
88
+ expect(screen.getByText('01')).toBeInTheDocument();
89
+ expect(screen.getByText('04')).toBeInTheDocument();
90
+ });
91
+ });
92
+
93
+ describe('CountdownCampaignBannerSection - expired', () => {
94
+ it('renders the default expired message for a past date', () => {
95
+ renderSection({ section: { properties: { 'end-date': '2000-01-01T00:00:00.000Z' } } });
96
+
97
+ expect(screen.getByText('Campaign ended')).toBeInTheDocument();
98
+ // No timer cells when expired.
99
+ expect(screen.queryByText('Days')).not.toBeInTheDocument();
100
+ });
101
+
102
+ it('renders a custom expired message when provided', () => {
103
+ renderSection({
104
+ section: {
105
+ properties: {
106
+ 'end-date': '2000-01-01T00:00:00.000Z',
107
+ 'expired-message': 'Bitti'
108
+ }
109
+ }
110
+ });
111
+
112
+ expect(screen.getByText('Bitti')).toBeInTheDocument();
113
+ expect(screen.queryByText('Campaign ended')).not.toBeInTheDocument();
114
+ });
115
+
116
+ it('an empty / missing end-date is NOT treated as expired (renders the zeroed timer)', () => {
117
+ renderSection({ section: { properties: {} } });
118
+
119
+ expect(screen.queryByText('Campaign ended')).not.toBeInTheDocument();
120
+ expect(screen.getByText('Days')).toBeInTheDocument();
121
+ // All units zeroed.
122
+ expect(screen.getAllByText('00').length).toBe(4);
123
+ });
124
+ });
125
+
126
+ describe('CountdownCampaignBannerSection - showSeconds toggle', () => {
127
+ it('shows four units (incl. Seconds) by default', () => {
128
+ renderSection({ section: { properties: { 'end-date': END_MS } } });
129
+
130
+ expect(screen.getByText('Days')).toBeInTheDocument();
131
+ expect(screen.getByText('Hours')).toBeInTheDocument();
132
+ expect(screen.getByText('Minutes')).toBeInTheDocument();
133
+ expect(screen.getByText('Seconds')).toBeInTheDocument();
134
+ });
135
+
136
+ it('hides the Seconds unit when show-seconds is false', () => {
137
+ renderSection({
138
+ section: { properties: { 'end-date': END_MS, 'show-seconds': false } }
139
+ });
140
+
141
+ expect(screen.getByText('Days')).toBeInTheDocument();
142
+ expect(screen.getByText('Hours')).toBeInTheDocument();
143
+ expect(screen.getByText('Minutes')).toBeInTheDocument();
144
+ expect(screen.queryByText('Seconds')).not.toBeInTheDocument();
145
+ });
146
+
147
+ it('accepts the string "false" for show-seconds', () => {
148
+ renderSection({
149
+ section: { properties: { 'end-date': END_MS, 'show-seconds': 'false' } }
150
+ });
151
+
152
+ expect(screen.queryByText('Seconds')).not.toBeInTheDocument();
153
+ });
154
+ });
155
+
156
+ describe('CountdownCampaignBannerSection - timer ticks', () => {
157
+ it('decrements the seconds cell after one interval tick', () => {
158
+ renderSection({ section: { properties: { 'end-date': END_MS } } });
159
+
160
+ expect(screen.getByText('04')).toBeInTheDocument();
161
+
162
+ act(() => {
163
+ jest.advanceTimersByTime(1000);
164
+ });
165
+
166
+ // seconds 04 -> 03 (minutes cell is also 03 at this point, so expect two)
167
+ expect(screen.queryByText('04')).not.toBeInTheDocument();
168
+ expect(screen.getAllByText('03').length).toBe(2);
169
+ });
170
+ });
171
+
172
+ describe('CountdownCampaignBannerSection - block layout', () => {
173
+ it('renders non-image foreground blocks via ThemeBlock', () => {
174
+ renderSection({
175
+ section: {
176
+ properties: { 'end-date': END_MS },
177
+ blocks: [
178
+ { id: 'b1', type: 'text', order: 1 },
179
+ { id: 'b2', type: 'button', order: 2 }
180
+ ]
181
+ }
182
+ });
183
+
184
+ const blocks = screen.getAllByTestId('tb');
185
+ const ids = blocks.map((el) => el.getAttribute('data-id'));
186
+ expect(ids).toEqual(expect.arrayContaining(['b1', 'b2']));
187
+ });
188
+ });