@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,144 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+
4
+ // Mock ThemeBlock (default export) so we only assert this section's own logic.
5
+ jest.mock('../../theme-block', () => ({
6
+ __esModule: true,
7
+ default: ({ block }: { block: { id: string; type: string } }) => (
8
+ <div data-testid="tb" data-id={block.id} data-type={block.type} />
9
+ )
10
+ }));
11
+
12
+ import NotificationBannerSection from '../notification-banner-section';
13
+ import type { Section } from '../../theme-section';
14
+ import * as utils from '../../utils';
15
+
16
+ const makeSection = (overrides: Partial<Section> = {}): Section =>
17
+ ({
18
+ id: 'sec-1',
19
+ type: 'notification-banner',
20
+ blocks: [],
21
+ styles: {},
22
+ properties: {},
23
+ ...overrides
24
+ } as unknown as Section);
25
+
26
+ describe('NotificationBannerSection', () => {
27
+ it('renders the dismiss button by default (dismissible defaults to true)', () => {
28
+ render(<NotificationBannerSection section={makeSection()} />);
29
+ expect(
30
+ screen.getByRole('button', { name: 'Dismiss notification' })
31
+ ).toBeInTheDocument();
32
+ });
33
+
34
+ it('hides the dismiss button when dismissible property is false', () => {
35
+ const section = makeSection({
36
+ properties: { dismissible: false } as unknown as Section['properties']
37
+ });
38
+ render(<NotificationBannerSection section={section} />);
39
+ expect(
40
+ screen.queryByRole('button', { name: 'Dismiss notification' })
41
+ ).not.toBeInTheDocument();
42
+ });
43
+
44
+ it('renders nothing after close when not in designer mode', () => {
45
+ const { container } = render(
46
+ <NotificationBannerSection section={makeSection()} />
47
+ );
48
+ fireEvent.click(
49
+ screen.getByRole('button', { name: 'Dismiss notification' })
50
+ );
51
+ expect(container).toBeEmptyDOMElement();
52
+ });
53
+
54
+ it('keeps the banner mounted with opacity-50 after close in designer mode', () => {
55
+ const { container } = render(
56
+ <NotificationBannerSection section={makeSection()} isDesigner />
57
+ );
58
+ fireEvent.click(
59
+ screen.getByRole('button', { name: 'Dismiss notification' })
60
+ );
61
+ const root = container.firstElementChild as HTMLElement;
62
+ expect(root).not.toBeNull();
63
+ expect(root).toHaveClass('opacity-50');
64
+ });
65
+
66
+ it('splits an icon block from content blocks and renders both via ThemeBlock', () => {
67
+ const section = makeSection({
68
+ blocks: [
69
+ { id: 'icon-1', type: 'icon' },
70
+ { id: 'text-1', type: 'text' },
71
+ { id: 'btn-1', type: 'button' }
72
+ ] as unknown as Section['blocks']
73
+ });
74
+ render(<NotificationBannerSection section={section} />);
75
+
76
+ const blocks = screen.getAllByTestId('tb');
77
+ // icon block + 2 content blocks = 3 total
78
+ expect(blocks).toHaveLength(3);
79
+
80
+ // The icon block is wrapped in the fixed 56x56 icon container.
81
+ const icon = blocks.find(b => b.getAttribute('data-id') === 'icon-1')!;
82
+ const iconWrapper = icon.parentElement as HTMLElement;
83
+ expect(iconWrapper).toHaveStyle({ width: '56px', height: '56px' });
84
+
85
+ // Content blocks live in the flex-1 container, not the icon wrapper.
86
+ const content = blocks.filter(b => b.getAttribute('data-id') !== 'icon-1');
87
+ expect(content).toHaveLength(2);
88
+ content.forEach(c => expect(c.parentElement).not.toBe(iconWrapper));
89
+ });
90
+
91
+ it('renders only content blocks (no icon wrapper) when there is no icon block', () => {
92
+ const section = makeSection({
93
+ blocks: [
94
+ { id: 'text-1', type: 'text' },
95
+ { id: 'text-2', type: 'text' }
96
+ ] as unknown as Section['blocks']
97
+ });
98
+ render(<NotificationBannerSection section={section} />);
99
+ expect(screen.getAllByTestId('tb')).toHaveLength(2);
100
+ });
101
+
102
+ // NOTE: jsdom's CSSOM (cssstyle) does not support linear-gradient values, so
103
+ // the `background` shorthand set inline is silently dropped and cannot be read
104
+ // back from the DOM. We assert the gradient resolution inputs via the util
105
+ // instead, which is what actually drives the gradient string.
106
+ it('resolves the gradient endpoints from style defaults when unset', () => {
107
+ const spy = jest.spyOn(utils, 'getResponsiveValue');
108
+ render(<NotificationBannerSection section={makeSection()} />);
109
+
110
+ expect(spy).toHaveBeenCalledWith(undefined, 'desktop', '#a8e063');
111
+ expect(spy).toHaveBeenCalledWith(undefined, 'desktop', '#d4fc79');
112
+ spy.mockRestore();
113
+ });
114
+
115
+ it('feeds configured gradient style values through to the resolver', () => {
116
+ const spy = jest.spyOn(utils, 'getResponsiveValue');
117
+ const section = makeSection({
118
+ styles: {
119
+ 'background-gradient-start': '#111111',
120
+ 'background-gradient-end': '#222222'
121
+ } as unknown as Section['styles']
122
+ });
123
+ render(<NotificationBannerSection section={section} />);
124
+
125
+ expect(spy).toHaveBeenCalledWith('#111111', 'desktop', '#a8e063');
126
+ expect(spy).toHaveBeenCalledWith('#222222', 'desktop', '#d4fc79');
127
+ spy.mockRestore();
128
+ });
129
+
130
+ it('applies the default border radius and numeric padding defaults', () => {
131
+ const { container } = render(
132
+ <NotificationBannerSection section={makeSection()} />
133
+ );
134
+ const root = container.firstElementChild as HTMLElement;
135
+ expect(root).toHaveStyle({
136
+ borderRadius: '16px',
137
+ paddingTop: '20px',
138
+ paddingRight: '24px',
139
+ paddingBottom: '20px',
140
+ paddingLeft: '24px',
141
+ gap: '16px'
142
+ });
143
+ });
144
+ });
@@ -0,0 +1,164 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import SectionWrapper from '../section-wrapper';
4
+ import type { Section } from '../../theme-section';
5
+
6
+ jest.mock('../../components/action-toolbar', () => ({
7
+ __esModule: true,
8
+ default: ({ label }: { label?: string }) => (
9
+ <div data-testid="action-toolbar">{label}</div>
10
+ )
11
+ }));
12
+
13
+ const makeSection = (overrides: Partial<Section> = {}): Section => ({
14
+ id: 'sec-1',
15
+ type: 'generic-section',
16
+ name: 'name',
17
+ label: 'My Section',
18
+ properties: {},
19
+ styles: {},
20
+ blocks: [],
21
+ order: 0,
22
+ hidden: false,
23
+ ...overrides
24
+ });
25
+
26
+ const renderWrapper = (props: Partial<React.ComponentProps<typeof SectionWrapper>> = {}) =>
27
+ render(
28
+ <SectionWrapper
29
+ section={props.section ?? makeSection()}
30
+ placeholderId="ph-1"
31
+ {...props}
32
+ >
33
+ <span>child content</span>
34
+ </SectionWrapper>
35
+ );
36
+
37
+ const getSection = (container: HTMLElement, id = 'sec-1') =>
38
+ container.querySelector(`[data-section-id="${id}"]`) as HTMLElement;
39
+
40
+ describe('SectionWrapper', () => {
41
+ it('always renders data-section-id', () => {
42
+ const { container } = renderWrapper({ section: makeSection({ id: 'abc' }) });
43
+ const el = getSection(container, 'abc');
44
+ expect(el).toBeInTheDocument();
45
+ expect(el.tagName).toBe('SECTION');
46
+ });
47
+
48
+ it('renders children inside the wrapper', () => {
49
+ renderWrapper();
50
+ expect(screen.getByText('child content')).toBeInTheDocument();
51
+ });
52
+
53
+ describe('newsletter data-* attributes', () => {
54
+ it('omits newsletter attributes for non-newsletter sections', () => {
55
+ const { container } = renderWrapper({
56
+ section: makeSection({
57
+ type: 'generic-section',
58
+ properties: {
59
+ 'api-endpoint': '/api/sub',
60
+ 'success-message': 'ok',
61
+ 'error-message': 'fail'
62
+ }
63
+ })
64
+ });
65
+ const el = getSection(container);
66
+ expect(el).not.toHaveAttribute('data-newsletter-signup');
67
+ expect(el).not.toHaveAttribute('data-newsletter-endpoint');
68
+ expect(el).not.toHaveAttribute('data-newsletter-success-message');
69
+ expect(el).not.toHaveAttribute('data-newsletter-error-message');
70
+ });
71
+
72
+ it('sets newsletter-signup="true" and populated attrs for newsletter sections', () => {
73
+ const { container } = renderWrapper({
74
+ section: makeSection({
75
+ type: 'newsletter-signup-banner',
76
+ properties: {
77
+ 'api-endpoint': '/api/sub',
78
+ 'success-message': 'Subscribed!',
79
+ 'error-message': 'Oops'
80
+ }
81
+ })
82
+ });
83
+ const el = getSection(container);
84
+ expect(el).toHaveAttribute('data-newsletter-signup', 'true');
85
+ expect(el).toHaveAttribute('data-newsletter-endpoint', '/api/sub');
86
+ expect(el).toHaveAttribute('data-newsletter-success-message', 'Subscribed!');
87
+ expect(el).toHaveAttribute('data-newsletter-error-message', 'Oops');
88
+ });
89
+
90
+ it('endpoint attribute is present (empty) but success/error omitted when empty', () => {
91
+ const { container } = renderWrapper({
92
+ section: makeSection({
93
+ type: 'newsletter-signup-banner',
94
+ properties: {}
95
+ })
96
+ });
97
+ const el = getSection(container);
98
+ // signup flag and endpoint render unconditionally for newsletter sections
99
+ expect(el).toHaveAttribute('data-newsletter-signup', 'true');
100
+ expect(el).toHaveAttribute('data-newsletter-endpoint', '');
101
+ // success/error are gated on non-empty values
102
+ expect(el).not.toHaveAttribute('data-newsletter-success-message');
103
+ expect(el).not.toHaveAttribute('data-newsletter-error-message');
104
+ });
105
+
106
+ it('reads responsive object properties via desktop/mobile fallback', () => {
107
+ const { container } = renderWrapper({
108
+ section: makeSection({
109
+ type: 'newsletter-signup-banner',
110
+ properties: {
111
+ 'api-endpoint': { mobile: '/api/m' },
112
+ 'success-message': { desktop: 'desk-success', mobile: 'mob-success' }
113
+ }
114
+ })
115
+ });
116
+ const el = getSection(container);
117
+ expect(el).toHaveAttribute('data-newsletter-endpoint', '/api/m');
118
+ expect(el).toHaveAttribute('data-newsletter-success-message', 'desk-success');
119
+ });
120
+ });
121
+
122
+ describe('hidden / display style', () => {
123
+ it('applies display:none when isDesigner and section.hidden', () => {
124
+ const { container } = renderWrapper({
125
+ isDesigner: true,
126
+ section: makeSection({ hidden: true })
127
+ });
128
+ expect(getSection(container).style.display).toBe('none');
129
+ });
130
+
131
+ it('does not hide when hidden but not in designer mode', () => {
132
+ const { container } = renderWrapper({
133
+ isDesigner: false,
134
+ section: makeSection({ hidden: true })
135
+ });
136
+ expect(getSection(container).style.display).toBe('');
137
+ });
138
+
139
+ it('does not hide when designer but not hidden', () => {
140
+ const { container } = renderWrapper({
141
+ isDesigner: true,
142
+ section: makeSection({ hidden: false })
143
+ });
144
+ expect(getSection(container).style.display).toBe('');
145
+ });
146
+ });
147
+
148
+ describe('ActionToolbar gating', () => {
149
+ it('renders ActionToolbar only when isDesigner && isSelected', () => {
150
+ renderWrapper({ isDesigner: true, isSelected: true });
151
+ expect(screen.getByTestId('action-toolbar')).toBeInTheDocument();
152
+ });
153
+
154
+ it('omits ActionToolbar when isDesigner but not selected', () => {
155
+ renderWrapper({ isDesigner: true, isSelected: false });
156
+ expect(screen.queryByTestId('action-toolbar')).not.toBeInTheDocument();
157
+ });
158
+
159
+ it('omits ActionToolbar when selected but not designer', () => {
160
+ renderWrapper({ isDesigner: false, isSelected: true });
161
+ expect(screen.queryByTestId('action-toolbar')).not.toBeInTheDocument();
162
+ });
163
+ });
164
+ });
@@ -0,0 +1,245 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ // Stub the recursive ThemeBlock (default export) with a countable marker that
5
+ // also surfaces the (possibly animated) block value so we can assert injection.
6
+ jest.mock('../../theme-block', () => ({
7
+ __esModule: true,
8
+ default: ({ block }: { block: { id: string; value: unknown } }) => (
9
+ <div data-testid="tb" data-id={block.id}>
10
+ {typeof block.value === 'string'
11
+ ? block.value
12
+ : JSON.stringify(block.value)}
13
+ </div>
14
+ )
15
+ }));
16
+
17
+ import StatsCounterSection from '../stats-counter-section';
18
+
19
+ const valueBlock = (
20
+ id: string,
21
+ text: string,
22
+ over: Record<string, unknown> = {}
23
+ ) =>
24
+ ({
25
+ id,
26
+ type: 'text',
27
+ label: 'Value',
28
+ value: text,
29
+ properties: { statsRole: 'value' },
30
+ styles: {},
31
+ order: 0,
32
+ hidden: false,
33
+ ...over
34
+ } as any);
35
+
36
+ const section = (over: Record<string, unknown> = {}) =>
37
+ ({
38
+ id: 'sec',
39
+ type: 'stats-counter',
40
+ name: 'stats',
41
+ label: 'stats',
42
+ properties: {},
43
+ styles: {},
44
+ blocks: [],
45
+ order: 0,
46
+ hidden: false,
47
+ ...over
48
+ } as any);
49
+
50
+ const root = (container: HTMLElement) =>
51
+ container.querySelector('.stats-counter-section') as HTMLElement;
52
+
53
+ const markerById = (container: HTMLElement, id: string) =>
54
+ container.querySelector(`[data-testid="tb"][data-id="${id}"]`) as HTMLElement;
55
+
56
+ describe('StatsCounterSection — max-width matrix', () => {
57
+ it('narrow -> max-w-4xl + mx-auto', () => {
58
+ const { container } = render(
59
+ <StatsCounterSection
60
+ section={section({ styles: { 'max-width': 'narrow' } })}
61
+ />
62
+ );
63
+ const el = root(container);
64
+ expect(el).toHaveClass('max-w-4xl');
65
+ expect(el).toHaveClass('mx-auto');
66
+ });
67
+
68
+ it('normal -> max-w-7xl + mx-auto', () => {
69
+ const { container } = render(
70
+ <StatsCounterSection
71
+ section={section({ styles: { 'max-width': 'normal' } })}
72
+ />
73
+ );
74
+ const el = root(container);
75
+ expect(el).toHaveClass('max-w-7xl');
76
+ expect(el).toHaveClass('mx-auto');
77
+ });
78
+
79
+ it('defaults to normal (max-w-7xl) when max-width is unset', () => {
80
+ const { container } = render(<StatsCounterSection section={section()} />);
81
+ const el = root(container);
82
+ expect(el).toHaveClass('max-w-7xl');
83
+ expect(el).toHaveClass('mx-auto');
84
+ });
85
+
86
+ it('none -> no max-width class and no mx-auto', () => {
87
+ const { container } = render(
88
+ <StatsCounterSection
89
+ section={section({ styles: { 'max-width': 'none' } })}
90
+ />
91
+ );
92
+ const el = root(container);
93
+ expect(el).not.toHaveClass('max-w-4xl');
94
+ expect(el).not.toHaveClass('max-w-7xl');
95
+ expect(el).not.toHaveClass('mx-auto');
96
+ });
97
+
98
+ it('full -> no max-width class and no mx-auto', () => {
99
+ const { container } = render(
100
+ <StatsCounterSection
101
+ section={section({ styles: { 'max-width': 'full' } })}
102
+ />
103
+ );
104
+ const el = root(container);
105
+ expect(el).not.toHaveClass('max-w-4xl');
106
+ expect(el).not.toHaveClass('max-w-7xl');
107
+ expect(el).not.toHaveClass('mx-auto');
108
+ });
109
+
110
+ it('resolves responsive max-width per breakpoint', () => {
111
+ const { container } = render(
112
+ <StatsCounterSection
113
+ section={section({
114
+ styles: { 'max-width': { desktop: 'narrow', mobile: 'none' } }
115
+ })}
116
+ currentBreakpoint="mobile"
117
+ />
118
+ );
119
+ const el = root(container);
120
+ expect(el).not.toHaveClass('max-w-4xl');
121
+ expect(el).not.toHaveClass('mx-auto');
122
+ });
123
+ });
124
+
125
+ describe('StatsCounterSection — countUpEnabled=false shows final value immediately', () => {
126
+ // count-up-enabled=false skips rAF entirely: the effect synchronously sets
127
+ // the values at progress=1, so the final formatted numbers are injected.
128
+ const renderFinal = () =>
129
+ render(
130
+ <StatsCounterSection
131
+ section={section({
132
+ properties: { 'count-up-enabled': false },
133
+ blocks: [
134
+ valueBlock('grp', '1,234'),
135
+ valueBlock('dec', '12.50'),
136
+ valueBlock('plain', '98'),
137
+ valueBlock('neg', '-5')
138
+ ]
139
+ })}
140
+ />
141
+ );
142
+
143
+ it('keeps grouping separators (useGrouping from comma token)', () => {
144
+ const { container } = renderFinal();
145
+ // formatAnimatedNumber uses toLocaleString() with NO explicit locale, so the
146
+ // grouping separator is host-locale dependent (not necessarily a comma).
147
+ expect(markerById(container, 'grp')).toHaveTextContent(
148
+ (1234).toLocaleString()
149
+ );
150
+ });
151
+
152
+ it('preserves decimal places parsed from the text', () => {
153
+ const { container } = renderFinal();
154
+ expect(markerById(container, 'dec')).toHaveTextContent('12.50');
155
+ });
156
+
157
+ it('renders plain integers without separators', () => {
158
+ const { container } = renderFinal();
159
+ expect(markerById(container, 'plain')).toHaveTextContent('98');
160
+ });
161
+
162
+ it('handles negative target values', () => {
163
+ const { container } = renderFinal();
164
+ expect(markerById(container, 'neg')).toHaveTextContent('-5');
165
+ });
166
+ });
167
+
168
+ describe('StatsCounterSection — getCounterTargetFromText / target detection', () => {
169
+ it('leaves non-numeric value blocks untouched (no target collected)', () => {
170
+ const { container } = render(
171
+ <StatsCounterSection
172
+ section={section({
173
+ properties: { 'count-up-enabled': false },
174
+ blocks: [valueBlock('txt', 'No number here')]
175
+ })}
176
+ />
177
+ );
178
+ expect(markerById(container, 'txt')).toHaveTextContent('No number here');
179
+ });
180
+
181
+ it('detects counter value blocks by label="Value" without statsRole', () => {
182
+ const { container } = render(
183
+ <StatsCounterSection
184
+ section={section({
185
+ properties: { 'count-up-enabled': false },
186
+ blocks: [
187
+ valueBlock('lbl', '7,000', { properties: {}, label: 'Value' })
188
+ ]
189
+ })}
190
+ />
191
+ );
192
+ expect(markerById(container, 'lbl')).toHaveTextContent(
193
+ (7000).toLocaleString()
194
+ );
195
+ });
196
+
197
+ it('does not treat non-value text blocks as counter targets', () => {
198
+ const { container } = render(
199
+ <StatsCounterSection
200
+ section={section({
201
+ properties: { 'count-up-enabled': false },
202
+ blocks: [
203
+ valueBlock('caption', '500', {
204
+ label: 'Caption',
205
+ properties: {}
206
+ })
207
+ ]
208
+ })}
209
+ />
210
+ );
211
+ // Not a target -> value left exactly as authored.
212
+ expect(markerById(container, 'caption')).toHaveTextContent('500');
213
+ });
214
+ });
215
+
216
+ describe('StatsCounterSection — block visibility', () => {
217
+ it('drops hidden blocks in production', () => {
218
+ const { container } = render(
219
+ <StatsCounterSection
220
+ section={section({
221
+ properties: { 'count-up-enabled': false },
222
+ blocks: [
223
+ valueBlock('shown', '10', { hidden: false }),
224
+ valueBlock('gone', '20', { hidden: true })
225
+ ]
226
+ })}
227
+ />
228
+ );
229
+ expect(markerById(container, 'shown')).toBeInTheDocument();
230
+ expect(markerById(container, 'gone')).not.toBeInTheDocument();
231
+ });
232
+
233
+ it('keeps hidden blocks in the designer', () => {
234
+ const { container } = render(
235
+ <StatsCounterSection
236
+ isDesigner
237
+ section={section({
238
+ properties: { 'count-up-enabled': false },
239
+ blocks: [valueBlock('gone', '20', { hidden: true })]
240
+ })}
241
+ />
242
+ );
243
+ expect(markerById(container, 'gone')).toBeInTheDocument();
244
+ });
245
+ });
@@ -0,0 +1,115 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+
4
+ // ThemeBlock is rendered for every leaf block (non-tab blocks, tab header
5
+ // children, and active tab content children). Mock it to expose each block id.
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 TabsSection from '../tabs-section';
14
+ import type { Section } from '../../theme-section';
15
+
16
+ type AnyBlock = Record<string, any>;
17
+
18
+ const tab = (id: string, headerChildId: string, contentChildId: string): AnyBlock => ({
19
+ id,
20
+ type: 'tab',
21
+ label: `tab-${id}`,
22
+ blocks: [
23
+ {
24
+ id: `${id}-header`,
25
+ label: 'Tab Header',
26
+ blocks: [{ id: headerChildId, type: 'text' }]
27
+ },
28
+ {
29
+ id: `${id}-content`,
30
+ label: 'Tab Content',
31
+ blocks: [{ id: contentChildId, type: 'text' }]
32
+ }
33
+ ]
34
+ });
35
+
36
+ const makeSection = (blocks: AnyBlock[]): Section =>
37
+ ({
38
+ id: 'section-1',
39
+ type: 'tabs',
40
+ name: 'Tabs',
41
+ label: 'Tabs',
42
+ properties: {},
43
+ styles: {},
44
+ blocks,
45
+ order: 0,
46
+ hidden: false
47
+ } as unknown as Section);
48
+
49
+ const idsRendered = () =>
50
+ screen.queryAllByTestId('tb').map((el) => el.getAttribute('data-id'));
51
+
52
+ describe('TabsSection', () => {
53
+ it('renders the "No tabs available" placeholder when there are no tab blocks', () => {
54
+ render(<TabsSection section={makeSection([])} />);
55
+
56
+ expect(screen.getByText('No tabs available')).toBeInTheDocument();
57
+ expect(screen.queryAllByTestId('tb')).toHaveLength(0);
58
+ });
59
+
60
+ it('renders non-tab blocks but not the tab wrapper blocks (tab vs non-tab split)', () => {
61
+ const section = makeSection([
62
+ { id: 'nt-1', type: 'header', blocks: [] },
63
+ tab('t1', 't1-h', 't1-c'),
64
+ tab('t2', 't2-h', 't2-c')
65
+ ]);
66
+
67
+ render(<TabsSection section={section} />);
68
+
69
+ const ids = idsRendered();
70
+ // Non-tab block is rendered directly.
71
+ expect(ids).toContain('nt-1');
72
+ // Both tab headers' children render.
73
+ expect(ids).toContain('t1-h');
74
+ expect(ids).toContain('t2-h');
75
+ // The tab wrapper blocks themselves are not rendered via ThemeBlock
76
+ // (only in designer mode).
77
+ expect(ids).not.toContain('t1');
78
+ expect(ids).not.toContain('t2');
79
+ });
80
+
81
+ it('defaults to the first tab and switches active content on header click', () => {
82
+ const section = makeSection([
83
+ tab('t1', 't1-h', 't1-c'),
84
+ tab('t2', 't2-h', 't2-c')
85
+ ]);
86
+
87
+ render(<TabsSection section={section} />);
88
+
89
+ // Default active index 0 -> only first tab content child rendered.
90
+ expect(idsRendered()).toContain('t1-c');
91
+ expect(idsRendered()).not.toContain('t2-c');
92
+
93
+ // Click the second tab header child; the click bubbles up to the header
94
+ // onClick div that calls setActiveTabIndex.
95
+ fireEvent.click(document.querySelector('[data-id="t2-h"]') as Element);
96
+
97
+ expect(idsRendered()).toContain('t2-c');
98
+ expect(idsRendered()).not.toContain('t1-c');
99
+ });
100
+
101
+ it('shows only the active tab content, never both at once', () => {
102
+ const section = makeSection([
103
+ tab('t1', 't1-h', 't1-c'),
104
+ tab('t2', 't2-h', 't2-c'),
105
+ tab('t3', 't3-h', 't3-c')
106
+ ]);
107
+
108
+ render(<TabsSection section={section} />);
109
+
110
+ const contentIds = ['t1-c', 't2-c', 't3-c'];
111
+ const shown = () => contentIds.filter((id) => idsRendered().includes(id));
112
+
113
+ expect(shown()).toEqual(['t1-c']);
114
+ });
115
+ });
@@ -295,11 +295,8 @@ const CouponBannerSection: React.FC<CouponBannerSectionProps> = ({
295
295
  rawSectionBorderRadius === undefined || rawSectionBorderRadius === null
296
296
  ? '0px'
297
297
  : String(rawSectionBorderRadius);
298
- const parsedSectionBorderRadius = Number.parseFloat(normalizedSectionBorderRadius);
299
298
  const sectionBorderRadius = resolveThemeCssVariables(
300
- Number.isFinite(parsedSectionBorderRadius) && parsedSectionBorderRadius === 12
301
- ? '0px'
302
- : normalizedSectionBorderRadius,
299
+ normalizedSectionBorderRadius,
303
300
  themeSettings
304
301
  );
305
302
 
@@ -57,7 +57,9 @@ export default function ThemeBlock({
57
57
  const BlockRenderer = blockRendererRegistry.getRenderer(block.type);
58
58
 
59
59
  if (!BlockRenderer) {
60
- return <div>Unknown block type: {block.type}</div>;
60
+ // Surface unknown types to authors in the designer, but never leak debug
61
+ // text to end users in production.
62
+ return isDesigner ? <div>Unknown block type: {block.type}</div> : null;
61
63
  }
62
64
 
63
65
  if (