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

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 (78) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,201 @@
1
+ import React from 'react';
2
+ import { render, fireEvent, screen } from '@testing-library/react';
3
+ import AccordionBlock from '../accordion-block';
4
+
5
+ jest.mock('../../theme-block', () => ({
6
+ __esModule: true,
7
+ default: ({ block }: any) => <div data-testid="tb" data-id={block.id} />
8
+ }));
9
+
10
+ const child = (over: Record<string, unknown> = {}) => ({
11
+ id: 'c',
12
+ type: 'text',
13
+ label: 'child',
14
+ hidden: false,
15
+ order: 0,
16
+ ...over
17
+ });
18
+
19
+ const props = (over: Record<string, unknown> = {}) =>
20
+ ({
21
+ block: {
22
+ id: 'acc',
23
+ type: 'accordion',
24
+ label: 'acc',
25
+ // No new property is set anywhere in this file: the state hooks are not
26
+ // opt-in flags, they are inert attributes, and that is what makes the
27
+ // markup comparison below the proof rather than a re-statement.
28
+ properties: {},
29
+ styles: {},
30
+ blocks: [
31
+ {
32
+ id: 'h',
33
+ label: 'Accordion Heading',
34
+ blocks: [child({ id: 'h1' })]
35
+ },
36
+ {
37
+ id: 'co',
38
+ label: 'Accordion Content',
39
+ blocks: [child({ id: 'c1' })]
40
+ }
41
+ ]
42
+ },
43
+ placeholderId: 'p',
44
+ sectionId: 's',
45
+ isDesigner: false,
46
+ ...over
47
+ } as any);
48
+
49
+ /**
50
+ * `data-part` and `data-state` are the only things this change adds to the
51
+ * DOM. Removing them again has to give back the markup the block emitted
52
+ * before, character for character — no extra wrapper, no reordered element,
53
+ * no changed inline style.
54
+ */
55
+ const withoutStateHooks = (html: string): string =>
56
+ html.replace(/\s(?:data-part|data-state)="[^"]*"/g, '');
57
+
58
+ // Exactly what the block rendered before the state hooks existed.
59
+ const CLOSED_BASELINE =
60
+ '<div style="cursor: pointer;">' +
61
+ '<div data-testid="tb" data-id="h1"></div>' +
62
+ '</div>' +
63
+ '<div style="display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.3s ease-in-out;">' +
64
+ '<div style="overflow: hidden;">' +
65
+ '<div data-testid="tb" data-id="c1"></div>' +
66
+ '</div>' +
67
+ '</div>';
68
+
69
+ const OPEN_BASELINE = CLOSED_BASELINE.replace(
70
+ 'grid-template-rows: 0fr;',
71
+ 'grid-template-rows: 1fr;'
72
+ );
73
+
74
+ describe('AccordionBlock — default output is unchanged', () => {
75
+ it('emits the pre-change markup once the inert hooks are removed (closed)', () => {
76
+ const { container } = render(<AccordionBlock {...props()} />);
77
+
78
+ expect(withoutStateHooks(container.innerHTML)).toBe(CLOSED_BASELINE);
79
+ });
80
+
81
+ it('emits the pre-change markup once the inert hooks are removed (open)', () => {
82
+ const { container } = render(<AccordionBlock {...props()} />);
83
+
84
+ fireEvent.click(container.querySelector('[data-part="header"]') as Element);
85
+
86
+ expect(withoutStateHooks(container.innerHTML)).toBe(OPEN_BASELINE);
87
+ });
88
+
89
+ it('leaves the empty placeholder byte-identical', () => {
90
+ const { container } = render(
91
+ <AccordionBlock
92
+ {...props({
93
+ block: { id: 'acc', type: 'accordion', properties: {}, blocks: [] }
94
+ })}
95
+ />
96
+ );
97
+
98
+ expect(withoutStateHooks(container.innerHTML)).toBe(
99
+ '<div class="p-4 text-gray-400 border border-dashed border-gray-300 rounded">' +
100
+ 'No accordion content' +
101
+ '</div>'
102
+ );
103
+ expect(screen.getByText('No accordion content')).toBeInTheDocument();
104
+ });
105
+
106
+ it('writes no data-state at all while closed', () => {
107
+ const { container } = render(<AccordionBlock {...props()} />);
108
+
109
+ expect(container.querySelectorAll('[data-state]')).toHaveLength(0);
110
+ });
111
+ });
112
+
113
+ describe('AccordionBlock — state hooks', () => {
114
+ const parts = ['header', 'panel', 'content'];
115
+
116
+ it('marks header, panel and content open only while expanded', () => {
117
+ const { container } = render(<AccordionBlock {...props()} />);
118
+
119
+ parts.forEach((part) => {
120
+ expect(
121
+ container.querySelector(`[data-part="${part}"][data-state~="open"]`)
122
+ ).toBeNull();
123
+ });
124
+
125
+ fireEvent.click(container.querySelector('[data-part="header"]') as Element);
126
+
127
+ parts.forEach((part) => {
128
+ expect(
129
+ container.querySelector(`[data-part="${part}"][data-state~="open"]`)
130
+ ).not.toBeNull();
131
+ });
132
+
133
+ fireEvent.click(container.querySelector('[data-part="header"]') as Element);
134
+
135
+ parts.forEach((part) => {
136
+ expect(
137
+ container.querySelector(`[data-part="${part}"][data-state~="open"]`)
138
+ ).toBeNull();
139
+ });
140
+ });
141
+
142
+ it('starts open, and marked open, when defaultOpen is set', () => {
143
+ const { container } = render(
144
+ <AccordionBlock
145
+ {...props({
146
+ block: {
147
+ id: 'acc',
148
+ type: 'accordion',
149
+ properties: { defaultOpen: true },
150
+ blocks: [
151
+ {
152
+ id: 'co',
153
+ label: 'Accordion Content',
154
+ blocks: [child({ id: 'c1' })]
155
+ }
156
+ ]
157
+ }
158
+ })}
159
+ />
160
+ );
161
+
162
+ expect(
163
+ container.querySelector('[data-part="panel"][data-state~="open"]')
164
+ ).not.toBeNull();
165
+ });
166
+
167
+ it('exposes the same state to a heading child through the header', () => {
168
+ // The chevron/plus glyph is an authored child block, so the only way a
169
+ // brand can restyle it per state is as a descendant of the open header.
170
+ const { container } = render(<AccordionBlock {...props()} />);
171
+
172
+ fireEvent.click(container.querySelector('[data-part="header"]') as Element);
173
+
174
+ expect(
175
+ container.querySelector(
176
+ '[data-part="header"][data-state~="open"] [data-id="h1"]'
177
+ )
178
+ ).not.toBeNull();
179
+ });
180
+
181
+ it('still toggles in the designer, and never on a toolbar click', () => {
182
+ const { container } = render(
183
+ <AccordionBlock {...props({ isDesigner: true })} />
184
+ );
185
+ const header = container.querySelector('[data-part="header"]') as Element;
186
+
187
+ const toolbar = document.createElement('div');
188
+ toolbar.setAttribute('data-action-toolbar', '');
189
+ header.appendChild(toolbar);
190
+
191
+ fireEvent.click(toolbar);
192
+ expect(
193
+ container.querySelector('[data-part="panel"][data-state~="open"]')
194
+ ).toBeNull();
195
+
196
+ fireEvent.click(header);
197
+ expect(
198
+ container.querySelector('[data-part="panel"][data-state~="open"]')
199
+ ).not.toBeNull();
200
+ });
201
+ });
@@ -0,0 +1,190 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import FacetFiltersBlock from '../facet-filters-block';
4
+ import { ThemePageContextProvider } from '../../theme-page-context';
5
+
6
+ /**
7
+ * The pill row is navigation, not a filter panel — which is the whole reason
8
+ * it exists as its own layout. Two things have to hold or it is not
9
+ * navigation: the options are anchors carrying a real href (a crawler follows
10
+ * it, a shopper opens it in a new tab), and none of that leaks into the
11
+ * designer, where a click would drop the author out of the page being edited.
12
+ */
13
+ const push = jest.fn();
14
+
15
+ jest.mock('next/navigation', () => ({
16
+ useRouter: () => ({ push: (...args: unknown[]) => push(...args) }),
17
+ usePathname: () => '/kadin-ayakkabi/',
18
+ useSearchParams: () => new URLSearchParams('page=3')
19
+ }));
20
+
21
+ const listingContext = {
22
+ listing: {
23
+ path: '/kadin-ayakkabi/',
24
+ products: [],
25
+ facets: [
26
+ {
27
+ key: 'category_ids',
28
+ name: 'Kategori',
29
+ search_key: 'category_ids',
30
+ widget_type: 'select',
31
+ choices: [
32
+ { label: 'AYAKKABI', value: '12', quantity: 1574, is_selected: true },
33
+ { label: 'Sandalet', value: '17', quantity: 210, is_selected: false }
34
+ ]
35
+ },
36
+ {
37
+ key: 'filterable_size',
38
+ name: 'Beden',
39
+ search_key: 'filterable_size',
40
+ widget_type: 'multiselect',
41
+ choices: [{ label: '38', value: '38', quantity: 40, is_selected: false }]
42
+ }
43
+ ]
44
+ }
45
+ };
46
+
47
+ const renderBlock = (
48
+ properties: Record<string, unknown>,
49
+ extra: Record<string, unknown> = {}
50
+ ) =>
51
+ render(
52
+ <ThemePageContextProvider value={listingContext}>
53
+ <FacetFiltersBlock
54
+ {...({
55
+ block: {
56
+ id: 'facets',
57
+ type: 'facet-filters',
58
+ styles: {},
59
+ properties,
60
+ order: 0,
61
+ hidden: false
62
+ },
63
+ placeholderId: 'p',
64
+ sectionId: 's',
65
+ ...extra
66
+ } as never)}
67
+ />
68
+ </ThemePageContextProvider>
69
+ );
70
+
71
+ const pills = (container: HTMLElement) =>
72
+ Array.from(container.querySelectorAll('[data-part="pill"]'));
73
+
74
+ beforeEach(() => push.mockClear());
75
+
76
+ describe('facet filters — the pill row', () => {
77
+ it('is not drawn unless the layout asks for it', () => {
78
+ const { container } = renderBlock({});
79
+
80
+ expect(container.querySelector('[data-facet-layout]')?.getAttribute('data-facet-layout')).toBe(
81
+ 'sidebar'
82
+ );
83
+ expect(pills(container)).toHaveLength(0);
84
+ // The rail keeps its heading and its checkbox — nothing about the default
85
+ // render moved.
86
+ expect(container.querySelectorAll('[data-facet-toggle]').length).toBeGreaterThan(0);
87
+ });
88
+
89
+ it('draws every choice as an anchor with a real href', () => {
90
+ const { container } = renderBlock({ layout: 'pills' });
91
+ const row = pills(container);
92
+
93
+ expect(row.length).toBe(3);
94
+ row.forEach((pill) => {
95
+ expect(pill.tagName).toBe('A');
96
+ expect(pill.getAttribute('href')).toMatch(/^\/kadin-ayakkabi\//);
97
+ });
98
+ // Any filter change invalidates the cursor the shopper was on.
99
+ row.forEach((pill) => {
100
+ expect(pill.getAttribute('href')).not.toContain('page=3');
101
+ });
102
+ });
103
+
104
+ it('marks the chosen subcategory and drops the headings', () => {
105
+ const { container } = renderBlock({ layout: 'pills' });
106
+ const selected = pills(container).filter(
107
+ (pill) => pill.getAttribute('data-facet-selected') === 'true'
108
+ );
109
+
110
+ expect(selected).toHaveLength(1);
111
+ expect(selected[0].textContent).toContain('AYAKKABI');
112
+ expect(selected[0].getAttribute('data-state')).toContain('selected');
113
+ expect(selected[0].getAttribute('aria-current')).toBe('page');
114
+ expect(container.querySelectorAll('[data-facet-toggle]')).toHaveLength(0);
115
+ expect(container.querySelectorAll('[data-part="checkbox"]')).toHaveLength(0);
116
+ });
117
+
118
+ it('offers an "All" link that clears its own facet and nothing else', () => {
119
+ const { container } = renderBlock({
120
+ layout: 'pills',
121
+ showAllOption: true,
122
+ allLabel: 'Tümü',
123
+ includeFacets: 'category_ids'
124
+ });
125
+ const [all] = pills(container);
126
+
127
+ expect(all.textContent).toBe('Tümü');
128
+ expect(all.getAttribute('data-facet-choice')).toBe('__all__');
129
+ // A facet with a selection means "All" is not the current page.
130
+ expect(all.getAttribute('data-facet-selected')).toBe('false');
131
+ expect(all.getAttribute('href')).not.toContain('category_ids');
132
+ });
133
+
134
+ it('shows only the facets the block was pointed at', () => {
135
+ const { container } = renderBlock({
136
+ layout: 'pills',
137
+ includeFacets: 'category_ids'
138
+ });
139
+
140
+ expect(container.querySelectorAll('[data-facet-group]')).toHaveLength(1);
141
+
142
+ const { container: excluded } = renderBlock({
143
+ excludeFacets: 'category_ids'
144
+ });
145
+
146
+ expect(
147
+ Array.from(excluded.querySelectorAll('[data-facet-group]')).map((el) =>
148
+ el.getAttribute('data-facet-group')
149
+ )
150
+ ).toEqual(['filterable_size']);
151
+ });
152
+
153
+ it('matches a facet by the name a designer reads off the page', () => {
154
+ const { container } = renderBlock({ layout: 'pills', includeFacets: 'beden' });
155
+
156
+ expect(
157
+ Array.from(container.querySelectorAll('[data-facet-group]')).map((el) =>
158
+ el.getAttribute('data-facet-group')
159
+ )
160
+ ).toEqual(['filterable_size']);
161
+ });
162
+
163
+ it('is inert in the designer', () => {
164
+ const { container } = renderBlock({ layout: 'pills' }, { isDesigner: true });
165
+ const [first] = pills(container);
166
+
167
+ expect(first.getAttribute('href')).toBeNull();
168
+
169
+ first.dispatchEvent(new MouseEvent('click', { bubbles: true }));
170
+
171
+ expect(push).not.toHaveBeenCalled();
172
+ });
173
+
174
+ it('leaves a modified click to the browser', () => {
175
+ const { container } = renderBlock({ layout: 'pills' });
176
+ const [first] = pills(container);
177
+
178
+ first.dispatchEvent(
179
+ new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true })
180
+ );
181
+
182
+ // The browser opens the href in a new tab; a router push would have
183
+ // navigated the tab the shopper meant to keep.
184
+ expect(push).not.toHaveBeenCalled();
185
+
186
+ first.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
187
+
188
+ expect(push).toHaveBeenCalledTimes(1);
189
+ });
190
+ });
@@ -0,0 +1,230 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import ProductGridBlock from '../product-grid-block';
4
+ import { ThemePageContextProvider } from '../../theme-page-context';
5
+ import { resolveListingContext } from '../listing-context';
6
+
7
+ /**
8
+ * The card view model grew three things: a percentage under a named basis, a
9
+ * second percentage off the previous price, and lookup-fed fields (badge,
10
+ * swatch colour) that only exist once an author maps them.
11
+ *
12
+ * All three are additions to the ROW, and the row is data — so the first test
13
+ * here is the one that matters: the grid, rendered with no lookup table
14
+ * supplied, must emit exactly the markup it emitted before any of this
15
+ * existed. The literal below was captured from the renderer at the previous
16
+ * commit; if an addition ever leaks into output, this fails on the diff rather
17
+ * than on a hand-written assertion someone could relax.
18
+ */
19
+ const payload = {
20
+ listing: {
21
+ title: 'Ayakkabı',
22
+ path: '/kadin-ayakkabi/',
23
+ products: [
24
+ {
25
+ pk: 41,
26
+ name: 'Deri Sandalet',
27
+ absolute_url: '/deri-sandalet-41/',
28
+ image: 'https://cdn.example.com/41.jpg',
29
+ price: '100.00',
30
+ retail_price: '200.00',
31
+ currency_type: 'TRY',
32
+ in_stock: true,
33
+ price_extra_field: { previous_price: '150.00' },
34
+ attributes_kwargs: {
35
+ badge: { value: 'new_season' }
36
+ },
37
+ extra_data: {
38
+ variants: [
39
+ {
40
+ attribute_key: 'integration_color',
41
+ options: [
42
+ {
43
+ label: 'Kırmızı',
44
+ value: 'kirmizi',
45
+ is_selectable: true,
46
+ is_selected: true,
47
+ product: { image: 'https://cdn.example.com/41-red.jpg' }
48
+ }
49
+ ]
50
+ }
51
+ ]
52
+ }
53
+ }
54
+ ]
55
+ }
56
+ };
57
+
58
+ const renderGrid = (properties: Record<string, unknown> = {}) =>
59
+ render(
60
+ <ThemePageContextProvider value={payload}>
61
+ <ProductGridBlock
62
+ {...({
63
+ block: {
64
+ id: 'grid',
65
+ type: 'product-grid',
66
+ styles: {},
67
+ properties,
68
+ order: 0,
69
+ hidden: false
70
+ },
71
+ placeholderId: 'p',
72
+ sectionId: 's'
73
+ } as never)}
74
+ />
75
+ </ThemePageContextProvider>
76
+ );
77
+
78
+ const firstProduct = (options?: Parameters<typeof resolveListingContext>[1]) =>
79
+ resolveListingContext(payload, options)!.products[0];
80
+
81
+ describe('product grid output is unchanged by the widened card model', () => {
82
+ it('emits byte-identical markup when no lookup table is authored', () => {
83
+ const { container } = renderGrid({
84
+ showRetailPrice: true,
85
+ showDiscountBadge: true
86
+ });
87
+
88
+ // Captured from the renderer BEFORE the row was widened. Whole-subtree, so
89
+ // a stray attribute or an accidentally rendered badge shows up here.
90
+ expect(container.innerHTML).toBe(
91
+ '<div data-product-grid="" data-product-grid-id="grid" data-part="list" style="display: grid; gap: var(--product-grid-gap, 16px);">' +
92
+ '<style>[data-product-grid-id="grid"]{grid-template-columns:repeat(4,minmax(0,1fr));}' +
93
+ '@media (max-width:1023px){[data-product-grid-id="grid"]{grid-template-columns:repeat(2,minmax(0,1fr));}}</style>' +
94
+ '<a href="/deri-sandalet-41/" data-product-card="" data-part="card" ' +
95
+ 'style="display: flex; flex-direction: column; gap: 8px; text-decoration: none;">' +
96
+ '<img alt="Deri Sandalet" loading="lazy" data-product-card-image="" data-part="image" ' +
97
+ 'style="width: 100%; height: auto; display: block; object-fit: cover;" ' +
98
+ 'src="https://cdn.example.com/41.jpg">' +
99
+ '<span data-product-card-badge="" data-part="badge" style="align-self: flex-start; ' +
100
+ 'padding: 2px 6px; font-size: 0.8em; line-height: 1.4; color: rgb(255, 255, 255); ' +
101
+ 'background: rgb(17, 24, 39); border-radius: 2px;">-50%</span>' +
102
+ '<span data-product-card-name="" data-part="name">Deri Sandalet</span>' +
103
+ '<span data-product-card-price="" data-part="price" ' +
104
+ 'style="display: flex; align-items: center; gap: 8px;">' +
105
+ '<s data-product-card-retail-price="" data-part="list-price" ' +
106
+ 'style="opacity: 0.6; font-size: 0.9em;">TRY&nbsp;200.00</s>' +
107
+ '<span data-part="sale-price">TRY&nbsp;100.00</span></span></a></div>'
108
+ );
109
+ });
110
+
111
+ it('publishes no lookup-fed field on the row when nothing is authored', () => {
112
+ const product = firstProduct();
113
+
114
+ expect(product.badge).toBeUndefined();
115
+ expect(product.colors?.[0].color).toBeUndefined();
116
+ // The image commerce sent is untouched by the swatch table being absent.
117
+ expect(product.colors?.[0].image).toBe('https://cdn.example.com/41-red.jpg');
118
+ });
119
+ });
120
+
121
+ describe('discount basis', () => {
122
+ it('keeps discountPercent on the retail basis it has always had', () => {
123
+ const product = firstProduct();
124
+
125
+ expect(product.discountPercent).toBe(50);
126
+ expect(product.discountPercentFromRetail).toBe(50);
127
+ });
128
+
129
+ it('publishes the previous-price basis alongside it', () => {
130
+ // 150 -> 100 is 33%, and it is a different number from the 50% off retail:
131
+ // a card that strikes the previous price was showing the wrong one.
132
+ expect(firstProduct().discountPercentFromPrevious).toBe(33);
133
+ });
134
+
135
+ it('leaves a basis absent rather than reporting 0% off it', () => {
136
+ const noPrevious = resolveListingContext({
137
+ listing: {
138
+ products: [
139
+ {
140
+ pk: 7,
141
+ name: 'Tam Fiyat',
142
+ absolute_url: '/tam-fiyat-7/',
143
+ price: '100.00',
144
+ retail_price: '100.00'
145
+ }
146
+ ]
147
+ }
148
+ })!.products[0];
149
+
150
+ expect(noPrevious.discountPercent).toBeUndefined();
151
+ expect(noPrevious.discountPercentFromRetail).toBeUndefined();
152
+ expect(noPrevious.discountPercentFromPrevious).toBeUndefined();
153
+ });
154
+
155
+ it('parses formatted amounts rather than trusting Number()', () => {
156
+ const formatted = resolveListingContext({
157
+ listing: {
158
+ products: [
159
+ {
160
+ pk: 8,
161
+ name: 'Biçimli',
162
+ absolute_url: '/bicimli-8/',
163
+ price: '1.000,00',
164
+ retail_price: '2.000,00',
165
+ price_extra_field: { previous_price: '1.250,00' }
166
+ }
167
+ ]
168
+ }
169
+ })!.products[0];
170
+
171
+ expect(formatted.discountPercentFromRetail).toBe(50);
172
+ expect(formatted.discountPercentFromPrevious).toBe(20);
173
+ });
174
+ });
175
+
176
+ describe('authored value lookups', () => {
177
+ const lookups = {
178
+ badge: {
179
+ attribute: 'badge',
180
+ entries: {
181
+ New_Season: {
182
+ label: 'Yeni Sezon',
183
+ image: 'https://cdn.example.com/new.svg',
184
+ color: '#0f766e'
185
+ }
186
+ }
187
+ },
188
+ swatch: { KIRMIZI: '#c81e1e' }
189
+ };
190
+
191
+ it('resolves a bare commerce key into label, image and colour', () => {
192
+ expect(firstProduct({ lookups }).badge).toEqual({
193
+ label: 'Yeni Sezon',
194
+ image: 'https://cdn.example.com/new.svg',
195
+ color: '#0f766e'
196
+ });
197
+ });
198
+
199
+ it('matches the key whatever case the integration wrote it in', () => {
200
+ // The payload says `new_season`, the table says `New_Season`, the swatch
201
+ // table says `KIRMIZI` — an author cannot be asked to enumerate casings.
202
+ expect(firstProduct({ lookups }).colors?.[0].color).toBe('#c81e1e');
203
+ });
204
+
205
+ it('publishes nothing for a value the table does not list', () => {
206
+ const unmapped = firstProduct({
207
+ lookups: { badge: { attribute: 'badge', entries: { sale: { label: 'İndirim' } } } }
208
+ });
209
+
210
+ // Not a fallback to the raw key: "new_season" on a card is the bug this
211
+ // mechanism exists to fix, so an unmapped value stays empty.
212
+ expect(unmapped.badge).toBeUndefined();
213
+ });
214
+
215
+ it('reads the attribute the author named, not a fixed one', () => {
216
+ expect(
217
+ firstProduct({
218
+ lookups: { badge: { attribute: 'not_the_badge_key', entries: { new_season: { label: 'x' } } } }
219
+ }).badge
220
+ ).toBeUndefined();
221
+ });
222
+
223
+ it('treats an empty table row as no match', () => {
224
+ expect(
225
+ firstProduct({
226
+ lookups: { badge: { attribute: 'badge', entries: { new_season: {} } } }
227
+ }).badge
228
+ ).toBeUndefined();
229
+ });
230
+ });
@@ -0,0 +1,80 @@
1
+ import {
2
+ parseListingLookups,
3
+ resolveListingContext
4
+ } from '../listing-context';
5
+
6
+ /**
7
+ * The three lookup controls are free text a merchandiser types, and the badge
8
+ * attribute key is used to index the product's attribute map. Anything that
9
+ * can be typed into that box has to be survivable: a listing page that throws
10
+ * takes the whole category down, and the author would have no way to tell
11
+ * which of the three boxes did it.
12
+ */
13
+ const context = (attributes: Record<string, { value: string }>) => ({
14
+ listing: {
15
+ products: [
16
+ {
17
+ pk: 1,
18
+ title: 'Tee',
19
+ url: '/p/tee',
20
+ price: '100.00',
21
+ currency: 'TRY',
22
+ attributes_kwargs: attributes
23
+ }
24
+ ]
25
+ }
26
+ });
27
+
28
+ const productWith = (
29
+ badgeAttribute: string,
30
+ badgeMap: string,
31
+ attributes: Record<string, { value: string }> = {
32
+ badge: { value: 'new_season' }
33
+ }
34
+ ) =>
35
+ resolveListingContext(context(attributes), {
36
+ currency: 'TRY',
37
+ lookups: parseListingLookups(badgeAttribute, badgeMap, '')
38
+ })?.products[0];
39
+
40
+ describe('badge attribute keys that are also Object.prototype members', () => {
41
+ // `constructor`, `toString`, `valueOf`, `hasOwnProperty` — every one of them
42
+ // answers on a plain object, and none of them answers with a string.
43
+ it.each(['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__'])(
44
+ 'reads %s as no badge instead of throwing',
45
+ (key) => {
46
+ let product;
47
+ expect(() => {
48
+ product = productWith(key, 'new_season|Yeni Sezon');
49
+ }).not.toThrow();
50
+
51
+ expect(product?.badge).toBeUndefined();
52
+ }
53
+ );
54
+
55
+ it('still reads a real attribute that shadows a prototype member', () => {
56
+ const product = productWith('toString', 'new_season|Yeni Sezon', {
57
+ toString: { value: 'new_season' }
58
+ });
59
+
60
+ expect(product?.badge?.label).toBe('Yeni Sezon');
61
+ });
62
+ });
63
+
64
+ describe('mapping keys that are also Object.prototype members', () => {
65
+ it('does not answer an unmapped value out of the table prototype', () => {
66
+ const product = productWith('badge', '__proto__|Poisoned', {
67
+ badge: { value: 'label' }
68
+ });
69
+
70
+ expect(product?.badge).toBeUndefined();
71
+ });
72
+
73
+ it('maps a value literally named like a prototype member', () => {
74
+ const product = productWith('badge', 'constructor|Yapıcı', {
75
+ badge: { value: 'constructor' }
76
+ });
77
+
78
+ expect(product?.badge?.label).toBe('Yapıcı');
79
+ });
80
+ });