@akinon/pz-theme 2.0.67-rc.0 → 2.0.67
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 +7 -31
- package/jest/__mocks__/akinon-next-plugin-module.js +18 -0
- package/jest.config.js +2 -0
- package/package.json +4 -4
- package/src/blocks/__tests__/basket-line-image.test.tsx +126 -0
- package/src/blocks/__tests__/basket-line-layout.test.tsx +336 -0
- package/src/blocks/__tests__/basket-line-price.test.tsx +227 -0
- package/src/blocks/__tests__/basket-offers.test.tsx +198 -0
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +169 -0
- package/src/blocks/basket-items-block.tsx +677 -45
- package/src/blocks/basket-summary-block.tsx +636 -92
- package/src/blocks/checkout-summary-block.tsx +8 -3
- package/src/blocks/product-block-utils.tsx +31 -1
- package/src/blocks/product-recommendations-block.tsx +209 -51
- package/src/theme-section-data-context.tsx +32 -0
- package/src/theme-section.tsx +13 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
3
|
+
import BasketItemsBlock from '../basket-items-block';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The basket page redesign shows three kinds of line price: the plain unit
|
|
7
|
+
* price, a struck retail figure with the sale price and a "-30%" badge, and
|
|
8
|
+
* the undiscounted price with an "in basket: 2.999 TL" badge when a basket
|
|
9
|
+
* campaign cut the line. It also puts a wishlist toggle in the picture's
|
|
10
|
+
* corner. All of it is opt-in; the default row is the single price line.
|
|
11
|
+
*/
|
|
12
|
+
let sessionStatus = 'authenticated';
|
|
13
|
+
let favoritesPayload: unknown = { results: [] };
|
|
14
|
+
const addFavorite = jest.fn(() => ({ unwrap: () => Promise.resolve({}) }));
|
|
15
|
+
const removeFavorite = jest.fn(() => ({ unwrap: () => Promise.resolve({}) }));
|
|
16
|
+
|
|
17
|
+
const money = {
|
|
18
|
+
decimals: 'never',
|
|
19
|
+
currencyDisplay: 'custom',
|
|
20
|
+
currencyText: 'TL',
|
|
21
|
+
currencyPosition: 'after'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const plainLine = {
|
|
25
|
+
id: 1,
|
|
26
|
+
quantity: 1,
|
|
27
|
+
price: '5499.00',
|
|
28
|
+
retail_price: '5499.00',
|
|
29
|
+
unit_price: '5499.00',
|
|
30
|
+
total_amount: '5499.00',
|
|
31
|
+
discount_amount: '0.00',
|
|
32
|
+
currency_type: 'try',
|
|
33
|
+
attributes: {},
|
|
34
|
+
product: {
|
|
35
|
+
pk: 1001,
|
|
36
|
+
name: 'Gömlek',
|
|
37
|
+
absolute_url: '/gomlek/',
|
|
38
|
+
price: '5499.00',
|
|
39
|
+
retail_price: '5499.00',
|
|
40
|
+
productimage_set: [{ image: '/gomlek.jpg' }]
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const discountedLine = {
|
|
45
|
+
...plainLine,
|
|
46
|
+
id: 2,
|
|
47
|
+
price: '3849.00',
|
|
48
|
+
unit_price: '3849.00',
|
|
49
|
+
total_amount: '3849.00',
|
|
50
|
+
retail_price: '5499.00',
|
|
51
|
+
product: { ...plainLine.product, pk: 1002, price: '3849.00' }
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const basketDiscountLine = {
|
|
55
|
+
...plainLine,
|
|
56
|
+
id: 3,
|
|
57
|
+
quantity: 2,
|
|
58
|
+
unit_price: '2999.00',
|
|
59
|
+
total_amount: '5998.00',
|
|
60
|
+
discount_amount: '5000.00',
|
|
61
|
+
product: { ...plainLine.product, pk: 1003 }
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
let basketData: unknown = { basketitem_set: [plainLine] };
|
|
65
|
+
|
|
66
|
+
jest.mock('next-auth/react', () => ({
|
|
67
|
+
useSession: () => ({ data: null, status: sessionStatus })
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
jest.mock('@akinon/next/redux/hooks', () => ({
|
|
71
|
+
__esModule: true,
|
|
72
|
+
useAppSelector: () => undefined,
|
|
73
|
+
useAppDispatch: () => jest.fn()
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
// Every `@akinon/next/data/client/*` subpath maps to the same mock file, so
|
|
77
|
+
// one factory has to serve the basket and the wishlist client alike.
|
|
78
|
+
jest.mock('@akinon/next/data/client/basket', () => ({
|
|
79
|
+
__esModule: true,
|
|
80
|
+
useGetBasketQuery: () => ({ data: basketData, isLoading: false }),
|
|
81
|
+
useUpdateQuantityMutation: () => [jest.fn(), { isLoading: false }],
|
|
82
|
+
useGetFavoritesQuery: (_arg: unknown, options?: { skip?: boolean }) => ({
|
|
83
|
+
data: options?.skip ? undefined : favoritesPayload,
|
|
84
|
+
isLoading: false
|
|
85
|
+
}),
|
|
86
|
+
useAddFavoriteMutation: () => [addFavorite, { isLoading: false }],
|
|
87
|
+
useRemoveFavoriteMutation: () => [removeFavorite, { isLoading: false }]
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
const renderLines = (properties: Record<string, unknown> = {}) =>
|
|
91
|
+
render(
|
|
92
|
+
<BasketItemsBlock
|
|
93
|
+
block={{
|
|
94
|
+
id: 'basket-items-1',
|
|
95
|
+
type: 'basket-items',
|
|
96
|
+
properties,
|
|
97
|
+
styles: {},
|
|
98
|
+
blocks: []
|
|
99
|
+
}}
|
|
100
|
+
placeholderId="p1"
|
|
101
|
+
sectionId="s1"
|
|
102
|
+
isDesigner={false}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const text = (root: ParentNode, part: string) =>
|
|
107
|
+
root.querySelector(`[data-part="${part}"]`)?.textContent ?? null;
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
sessionStatus = 'authenticated';
|
|
111
|
+
favoritesPayload = { results: [] };
|
|
112
|
+
basketData = { basketitem_set: [plainLine] };
|
|
113
|
+
addFavorite.mockClear();
|
|
114
|
+
removeFavorite.mockClear();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('basket-items price extras', () => {
|
|
118
|
+
it('keeps the single price line by default', () => {
|
|
119
|
+
basketData = { basketitem_set: [discountedLine] };
|
|
120
|
+
const { container } = renderLines(money);
|
|
121
|
+
|
|
122
|
+
expect(container.querySelector('[data-part="price-row"]')).toBeNull();
|
|
123
|
+
expect(container.querySelector('[data-part="retail-price"]')).toBeNull();
|
|
124
|
+
expect(text(container, 'price')).toBe('3,849 TL');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('strikes the retail figure and badges the cut on a discounted line', () => {
|
|
128
|
+
basketData = { basketitem_set: [discountedLine] };
|
|
129
|
+
const { container } = renderLines({
|
|
130
|
+
...money,
|
|
131
|
+
showRetailPrice: true,
|
|
132
|
+
showDiscountBadge: true,
|
|
133
|
+
discountBadgeFormat: '-%{percent}'
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(text(container, 'retail-price')).toBe('5,499 TL');
|
|
137
|
+
const price = container.querySelector('[data-part="price"]');
|
|
138
|
+
expect(price?.textContent).toBe('3,849 TL');
|
|
139
|
+
expect(price?.getAttribute('data-state')).toBe('selected');
|
|
140
|
+
expect(text(container, 'discount-badge')).toBe('-%30');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('draws nothing extra on a line priced at retail', () => {
|
|
144
|
+
const { container } = renderLines({
|
|
145
|
+
...money,
|
|
146
|
+
showRetailPrice: true,
|
|
147
|
+
showDiscountBadge: true
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(container.querySelector('[data-part="retail-price"]')).toBeNull();
|
|
151
|
+
expect(container.querySelector('[data-part="discount-badge"]')).toBeNull();
|
|
152
|
+
expect(container.querySelector('[data-part="price"]')?.getAttribute('data-state')).toBeNull();
|
|
153
|
+
expect(text(container, 'price')).toBe('5,499 TL');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('shows the undiscounted price with the in-basket figure when a campaign cut the line', () => {
|
|
157
|
+
basketData = { basketitem_set: [basketDiscountLine] };
|
|
158
|
+
const { container } = renderLines({
|
|
159
|
+
...money,
|
|
160
|
+
showBasketDiscount: true,
|
|
161
|
+
basketDiscountFormat: 'SEPETTE {price}'
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(text(container, 'price')).toBe('5,499 TL');
|
|
165
|
+
expect(text(container, 'basket-price')).toBe('SEPETTE 2,999 TL');
|
|
166
|
+
expect(container.querySelector('[data-part="retail-price"]')).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('basket-items favourite toggle', () => {
|
|
171
|
+
it('is absent by default and the picture keeps its bare link', () => {
|
|
172
|
+
const { container } = renderLines();
|
|
173
|
+
|
|
174
|
+
expect(container.querySelector('[data-part="favorite"]')).toBeNull();
|
|
175
|
+
expect(container.querySelector('[data-part="media"]')).toBeNull();
|
|
176
|
+
expect(container.querySelector('[data-part="image"]')?.parentElement?.tagName).toBe('A');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('adds a product to the favourites and removes a favourited one', () => {
|
|
180
|
+
const { container, rerender } = renderLines({ showFavorite: true });
|
|
181
|
+
|
|
182
|
+
const media = container.querySelector('[data-part="media"]');
|
|
183
|
+
expect(media?.querySelector('[data-part="image"]')).not.toBeNull();
|
|
184
|
+
const toggle = media?.querySelector(
|
|
185
|
+
'[data-part="favorite"]'
|
|
186
|
+
) as HTMLButtonElement;
|
|
187
|
+
expect(toggle.tagName).toBe('BUTTON');
|
|
188
|
+
expect(toggle.getAttribute('aria-pressed')).toBe('false');
|
|
189
|
+
|
|
190
|
+
fireEvent.click(toggle);
|
|
191
|
+
expect(addFavorite).toHaveBeenCalledWith(1001);
|
|
192
|
+
|
|
193
|
+
favoritesPayload = { results: [{ pk: 77, product: { pk: 1001 } }] };
|
|
194
|
+
rerender(
|
|
195
|
+
<BasketItemsBlock
|
|
196
|
+
block={{
|
|
197
|
+
id: 'basket-items-1',
|
|
198
|
+
type: 'basket-items',
|
|
199
|
+
properties: { showFavorite: true },
|
|
200
|
+
styles: {},
|
|
201
|
+
blocks: []
|
|
202
|
+
}}
|
|
203
|
+
placeholderId="p1"
|
|
204
|
+
sectionId="s1"
|
|
205
|
+
isDesigner={false}
|
|
206
|
+
/>
|
|
207
|
+
);
|
|
208
|
+
const active = container.querySelector(
|
|
209
|
+
'[data-part="favorite"]'
|
|
210
|
+
) as HTMLButtonElement;
|
|
211
|
+
expect(active.getAttribute('data-state')).toBe('selected');
|
|
212
|
+
fireEvent.click(active);
|
|
213
|
+
expect(removeFavorite).toHaveBeenCalledWith(77);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('sends a guest to the login page instead', () => {
|
|
217
|
+
sessionStatus = 'unauthenticated';
|
|
218
|
+
const { container } = renderLines({
|
|
219
|
+
showFavorite: true,
|
|
220
|
+
favoriteLoginHref: '/users/auth'
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const link = container.querySelector('[data-part="favorite"]');
|
|
224
|
+
expect(link?.tagName).toBe('A');
|
|
225
|
+
expect(link?.getAttribute('href')).toBe('/users/auth');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
3
|
+
import BasketSummaryBlock from '../basket-summary-block';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The basket page redesign surfaces the shopper's coupon wallet inside the
|
|
7
|
+
* summary: a card per active offer with its validity date, label, value and
|
|
8
|
+
* an apply control that puts the offer's voucher code on the basket. Nothing
|
|
9
|
+
* renders for guests or by default — the wallet is opt-in through
|
|
10
|
+
* `showOffers`.
|
|
11
|
+
*/
|
|
12
|
+
let sessionStatus = 'authenticated';
|
|
13
|
+
let offersPayload: unknown = {
|
|
14
|
+
discounts: [
|
|
15
|
+
{
|
|
16
|
+
pk: 11,
|
|
17
|
+
label: 'GANT REDESIGN',
|
|
18
|
+
amount: '100.00',
|
|
19
|
+
voucher_code: 'REDESIGN100',
|
|
20
|
+
start_datetime: '2025-03-01T00:00:00Z',
|
|
21
|
+
end_datetime: '2025-04-10T12:00:00Z'
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
pk: 12,
|
|
25
|
+
label: 'Yüzde',
|
|
26
|
+
percentage: 10,
|
|
27
|
+
voucher_code: null,
|
|
28
|
+
end_datetime: '2025-05-01T12:00:00Z'
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
};
|
|
32
|
+
let voucherCode: string | null = null;
|
|
33
|
+
|
|
34
|
+
const applyVoucher = jest.fn(() => ({
|
|
35
|
+
unwrap: () => Promise.resolve({ total_amount: '1.00' })
|
|
36
|
+
}));
|
|
37
|
+
const removeVoucher = jest.fn(() => ({
|
|
38
|
+
unwrap: () => Promise.resolve({ total_amount: '2.00' })
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
jest.mock('next-auth/react', () => ({
|
|
42
|
+
useSession: () => ({ data: null, status: sessionStatus })
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
// Every `@akinon/next/data/client/*` subpath maps to the same mock file, so
|
|
46
|
+
// one factory has to serve the basket and the account client alike.
|
|
47
|
+
jest.mock('@akinon/next/data/client/basket', () => ({
|
|
48
|
+
__esModule: true,
|
|
49
|
+
useGetBasketOffersQuery: (_arg: unknown, options?: { skip?: boolean }) => ({
|
|
50
|
+
data: options?.skip ? undefined : offersPayload,
|
|
51
|
+
isLoading: false
|
|
52
|
+
}),
|
|
53
|
+
basketApi: { util: { updateQueryData: () => ({ type: 'noop' }) } },
|
|
54
|
+
useGetBasketQuery: () => ({
|
|
55
|
+
data: {
|
|
56
|
+
basketitem_set: [{ currency_type: 'try' }],
|
|
57
|
+
total_quantity: 1,
|
|
58
|
+
total_product_amount: '500.00',
|
|
59
|
+
total_amount: '500.00',
|
|
60
|
+
voucher_code: voucherCode
|
|
61
|
+
},
|
|
62
|
+
isLoading: false
|
|
63
|
+
}),
|
|
64
|
+
useUpdateQuantityMutation: () => [jest.fn(), { isLoading: false }],
|
|
65
|
+
useApplyVoucherCodeMutation: () => [applyVoucher, { isLoading: false }],
|
|
66
|
+
useRemoveVoucherCodeMutation: () => [removeVoucher, { isLoading: false }],
|
|
67
|
+
useGetFavoritesQuery: () => ({ data: undefined, isLoading: false }),
|
|
68
|
+
useAddFavoriteMutation: () => [jest.fn(), { isLoading: false }],
|
|
69
|
+
useRemoveFavoriteMutation: () => [jest.fn(), { isLoading: false }]
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
jest.mock('@akinon/next/redux/hooks', () => ({
|
|
73
|
+
__esModule: true,
|
|
74
|
+
useAppSelector: () => undefined,
|
|
75
|
+
useAppDispatch: () => jest.fn()
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
const renderSummary = (
|
|
79
|
+
properties: Record<string, unknown> = {},
|
|
80
|
+
isDesigner = false
|
|
81
|
+
) =>
|
|
82
|
+
render(
|
|
83
|
+
<BasketSummaryBlock
|
|
84
|
+
block={{
|
|
85
|
+
id: 'basket-summary-1',
|
|
86
|
+
type: 'basket-summary',
|
|
87
|
+
properties,
|
|
88
|
+
styles: {},
|
|
89
|
+
blocks: []
|
|
90
|
+
}}
|
|
91
|
+
placeholderId="p1"
|
|
92
|
+
sectionId="s1"
|
|
93
|
+
isDesigner={isDesigner}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
sessionStatus = 'authenticated';
|
|
99
|
+
voucherCode = null;
|
|
100
|
+
applyVoucher.mockClear();
|
|
101
|
+
removeVoucher.mockClear();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('basket-summary coupon wallet', () => {
|
|
105
|
+
it('stays absent by default', () => {
|
|
106
|
+
const { container } = renderSummary();
|
|
107
|
+
|
|
108
|
+
expect(container.querySelector('[data-part="offers"]')).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('lists the wallet as cards above the voucher panel and applies an offer', () => {
|
|
112
|
+
const { container } = renderSummary({
|
|
113
|
+
showOffers: true,
|
|
114
|
+
offerApplyLabel: 'Uygula',
|
|
115
|
+
couponPlacement: 'after-cta',
|
|
116
|
+
decimals: 'never',
|
|
117
|
+
currencyDisplay: 'custom',
|
|
118
|
+
currencyText: 'TL',
|
|
119
|
+
currencyPosition: 'after'
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const root = container.querySelector('[data-part="root"]') as HTMLElement;
|
|
123
|
+
const order = Array.from(root.children).map((child) =>
|
|
124
|
+
child.getAttribute('data-part')
|
|
125
|
+
);
|
|
126
|
+
expect(order).toEqual(['totals', 'cta', 'offers', 'coupon']);
|
|
127
|
+
|
|
128
|
+
const cards = container.querySelectorAll('[data-part="offer"]');
|
|
129
|
+
expect(cards).toHaveLength(2);
|
|
130
|
+
|
|
131
|
+
const first = cards[0];
|
|
132
|
+
// The end date prints in the storefront locale (the hooks mock is en-US;
|
|
133
|
+
// a tr storefront shows 10.04.2025).
|
|
134
|
+
expect(first.querySelector('[data-part="offer-date"]')?.textContent).toBe(
|
|
135
|
+
'04/10/2025'
|
|
136
|
+
);
|
|
137
|
+
expect(first.querySelector('[data-part="offer-label"]')?.textContent).toBe(
|
|
138
|
+
'GANT REDESIGN'
|
|
139
|
+
);
|
|
140
|
+
expect(first.querySelector('[data-part="offer-value"]')?.textContent).toBe(
|
|
141
|
+
'100 TL'
|
|
142
|
+
);
|
|
143
|
+
const apply = first.querySelector(
|
|
144
|
+
'[data-part="offer-apply"]'
|
|
145
|
+
) as HTMLButtonElement;
|
|
146
|
+
expect(apply.textContent).toContain('Uygula');
|
|
147
|
+
|
|
148
|
+
// An automatic campaign without a code is informational only.
|
|
149
|
+
expect(cards[1].querySelector('[data-part="offer-value"]')?.textContent).toBe(
|
|
150
|
+
'%10'
|
|
151
|
+
);
|
|
152
|
+
expect(cards[1].querySelector('[data-part="offer-apply"]')).toBeNull();
|
|
153
|
+
|
|
154
|
+
fireEvent.click(apply);
|
|
155
|
+
expect(applyVoucher).toHaveBeenCalledWith({ voucher_code: 'REDESIGN100' });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('marks the applied offer and lets the shopper take it off again', () => {
|
|
159
|
+
voucherCode = 'REDESIGN100';
|
|
160
|
+
const { container } = renderSummary({
|
|
161
|
+
showOffers: true,
|
|
162
|
+
offerRemoveLabel: 'Kaldır'
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const card = container.querySelector('[data-part="offer"]') as HTMLElement;
|
|
166
|
+
expect(card.getAttribute('data-state')).toBe('selected');
|
|
167
|
+
const control = card.querySelector(
|
|
168
|
+
'[data-part="offer-apply"]'
|
|
169
|
+
) as HTMLButtonElement;
|
|
170
|
+
expect(control.textContent).toContain('Kaldır');
|
|
171
|
+
|
|
172
|
+
fireEvent.click(control);
|
|
173
|
+
expect(removeVoucher).toHaveBeenCalled();
|
|
174
|
+
expect(applyVoucher).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('shows nothing to guests and a sample card to the designer', () => {
|
|
178
|
+
sessionStatus = 'unauthenticated';
|
|
179
|
+
|
|
180
|
+
const guest = renderSummary({ showOffers: true });
|
|
181
|
+
expect(guest.container.querySelector('[data-part="offers"]')).toBeNull();
|
|
182
|
+
guest.unmount();
|
|
183
|
+
|
|
184
|
+
const designer = renderSummary({ showOffers: true }, true);
|
|
185
|
+
const card = designer.container.querySelector('[data-part="offer"]');
|
|
186
|
+
expect(card).not.toBeNull();
|
|
187
|
+
expect(
|
|
188
|
+
(card?.querySelector('[data-part="offer-apply"]') as HTMLButtonElement)
|
|
189
|
+
.disabled
|
|
190
|
+
).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('caps the list with offersLimit', () => {
|
|
194
|
+
const { container } = renderSummary({ showOffers: true, offersLimit: 1 });
|
|
195
|
+
|
|
196
|
+
expect(container.querySelectorAll('[data-part="offer"]')).toHaveLength(1);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -700,3 +700,172 @@ describe('the strip root is a real focus target only where it is needed', () =>
|
|
|
700
700
|
expect(document.activeElement).toBe(root);
|
|
701
701
|
});
|
|
702
702
|
});
|
|
703
|
+
|
|
704
|
+
describe('editorial header and arrows', () => {
|
|
705
|
+
const CARDS = Array.from({ length: 3 }, (_, index) => ({
|
|
706
|
+
pk: 100 + index,
|
|
707
|
+
name: `Card ${index}`,
|
|
708
|
+
absolute_url: `/card-${index}`,
|
|
709
|
+
productimage_set: [{ image: `https://cdn/card-${index}.jpg` }],
|
|
710
|
+
price: '5499.00',
|
|
711
|
+
currency_type: 'try'
|
|
712
|
+
}));
|
|
713
|
+
|
|
714
|
+
beforeEach(() => {
|
|
715
|
+
mockWidget = { products: CARDS };
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
afterEach(() => {
|
|
719
|
+
mockWidget = undefined;
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it('keeps the title above the arrows by default', () => {
|
|
723
|
+
const { container } = renderBlock(
|
|
724
|
+
<ProductRecommendationsBlock
|
|
725
|
+
{...props({ collectionId: 'c1', title: 'Picked', showArrows: true })}
|
|
726
|
+
/>
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
expect(container.querySelector('[data-part="header"]')).toBeNull();
|
|
730
|
+
const root = container.firstElementChild as HTMLElement;
|
|
731
|
+
const parts = Array.from(root.children).map((child) =>
|
|
732
|
+
child.getAttribute('data-part')
|
|
733
|
+
);
|
|
734
|
+
expect(parts.slice(0, 3)).toEqual([null, 'title', 'arrow-list']);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it('puts the title and the arrows on one row when asked to', () => {
|
|
738
|
+
const { container } = renderBlock(
|
|
739
|
+
<ProductRecommendationsBlock
|
|
740
|
+
{...props({
|
|
741
|
+
collectionId: 'c1',
|
|
742
|
+
title: 'Picked',
|
|
743
|
+
showArrows: true,
|
|
744
|
+
headerLayout: 'inline',
|
|
745
|
+
arrowIcon: 'arrow'
|
|
746
|
+
})}
|
|
747
|
+
/>
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
const header = container.querySelector('[data-part="header"]');
|
|
751
|
+
expect(header).not.toBeNull();
|
|
752
|
+
expect(header?.querySelector('[data-part="title"]')).not.toBeNull();
|
|
753
|
+
expect(header?.querySelector('[data-part="arrow-list"]')).not.toBeNull();
|
|
754
|
+
// The line arrow is drawn on an 8×8 canvas; the default chevron is 24×24.
|
|
755
|
+
expect(
|
|
756
|
+
header?.querySelector('[data-product-recommendations-arrow="prev"] svg')
|
|
757
|
+
?.getAttribute('viewBox')
|
|
758
|
+
).toBe('0 0 8 8');
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('disables the previous arrow at the start of the track', () => {
|
|
762
|
+
const { container } = renderBlock(
|
|
763
|
+
<ProductRecommendationsBlock
|
|
764
|
+
{...props({ collectionId: 'c1', showArrows: true })}
|
|
765
|
+
/>
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
const prev = container.querySelector(
|
|
769
|
+
'[data-product-recommendations-arrow="prev"]'
|
|
770
|
+
) as HTMLButtonElement;
|
|
771
|
+
const next = container.querySelector(
|
|
772
|
+
'[data-product-recommendations-arrow="next"]'
|
|
773
|
+
) as HTMLButtonElement;
|
|
774
|
+
// jsdom lays nothing out, so scrollLeft stays 0: start of track (and
|
|
775
|
+
// nothing to scroll to, so the next arrow rests too).
|
|
776
|
+
expect(prev.getAttribute('data-state')).toBe('disabled');
|
|
777
|
+
expect(prev.getAttribute('aria-disabled')).toBe('true');
|
|
778
|
+
expect(next.getAttribute('aria-label')).toBe('Next products');
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it('formats card prices with the shared money options', () => {
|
|
782
|
+
const { container } = renderBlock(
|
|
783
|
+
<ProductRecommendationsBlock
|
|
784
|
+
{...props({
|
|
785
|
+
collectionId: 'c1',
|
|
786
|
+
decimals: 'never',
|
|
787
|
+
currencyDisplay: 'custom',
|
|
788
|
+
currencyText: 'TL',
|
|
789
|
+
currencyPosition: 'after'
|
|
790
|
+
})}
|
|
791
|
+
/>
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
expect(
|
|
795
|
+
container.querySelector('[data-part="card-price"]')?.textContent
|
|
796
|
+
).toBe('5,499 TL');
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
describe('section source', () => {
|
|
801
|
+
it('draws the rows the section data source resolved to, no collection id needed', () => {
|
|
802
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
803
|
+
const { ThemeSectionDataProvider } = require('../../theme-section-data-context');
|
|
804
|
+
const items = [
|
|
805
|
+
{
|
|
806
|
+
pk: 501,
|
|
807
|
+
name: 'Editorial Shirt',
|
|
808
|
+
absolute_url: '/editorial-shirt',
|
|
809
|
+
productimage_set: [{ image: 'https://cdn/editorial.jpg' }],
|
|
810
|
+
price: '5499.00',
|
|
811
|
+
currency_type: 'try'
|
|
812
|
+
}
|
|
813
|
+
];
|
|
814
|
+
|
|
815
|
+
const { container } = render(
|
|
816
|
+
<ThemeSectionDataProvider items={items}>
|
|
817
|
+
<ProductRecommendationsBlock
|
|
818
|
+
{...props({ source: 'section', title: 'Picked', showArrows: true })}
|
|
819
|
+
/>
|
|
820
|
+
</ThemeSectionDataProvider>
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
expect(container.querySelector('[data-part="card-name"]')?.textContent).toBe(
|
|
824
|
+
'Editorial Shirt'
|
|
825
|
+
);
|
|
826
|
+
expect(
|
|
827
|
+
container.querySelector('[data-part="card-image"]')?.getAttribute('src')
|
|
828
|
+
).toBe('https://cdn/editorial.jpg');
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it('reads the price of an Omnitron-shaped row (editor preview)', () => {
|
|
832
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
833
|
+
const { ThemeSectionDataProvider } = require('../../theme-section-data-context');
|
|
834
|
+
const items = [
|
|
835
|
+
{
|
|
836
|
+
pk: 502,
|
|
837
|
+
name: 'Omnitron Shirt',
|
|
838
|
+
productimage_set: [{ image: 'https://cdn/omni.jpg' }],
|
|
839
|
+
active_price: { price: '3999.00', retail_price: '5999.00', currency_type: 'try' }
|
|
840
|
+
}
|
|
841
|
+
];
|
|
842
|
+
|
|
843
|
+
const { container } = render(
|
|
844
|
+
<ThemeSectionDataProvider items={items}>
|
|
845
|
+
<ProductRecommendationsBlock
|
|
846
|
+
{...props({
|
|
847
|
+
source: 'section',
|
|
848
|
+
decimals: 'never',
|
|
849
|
+
currencyDisplay: 'custom',
|
|
850
|
+
currencyText: 'TL',
|
|
851
|
+
currencyPosition: 'after'
|
|
852
|
+
})}
|
|
853
|
+
/>
|
|
854
|
+
</ThemeSectionDataProvider>
|
|
855
|
+
);
|
|
856
|
+
|
|
857
|
+
expect(container.querySelector('[data-part="card-price"]')?.textContent).toBe(
|
|
858
|
+
'3,999 TL'
|
|
859
|
+
);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
it('tells the designer to pick a collection when the section has none', () => {
|
|
863
|
+
const { container } = renderBlock(
|
|
864
|
+
<ProductRecommendationsBlock
|
|
865
|
+
{...props({ source: 'section' }, { isDesigner: true })}
|
|
866
|
+
/>
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
expect(container.textContent).toContain('Source panel');
|
|
870
|
+
});
|
|
871
|
+
});
|