@akinon/pz-theme 2.0.168-rc.0 → 2.0.169-rc.0
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.
|
|
3
|
+
## 2.0.169-rc.0
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
- Updated dependencies [143be2b9d]
|
|
24
24
|
- Updated dependencies [7889b08fe]
|
|
25
25
|
- Updated dependencies [9f8cd3bc5]
|
|
26
|
-
- Updated dependencies [
|
|
26
|
+
- Updated dependencies [d635d345e]
|
|
27
27
|
- Updated dependencies [bfafa3f49]
|
|
28
28
|
- Updated dependencies [d860c917]
|
|
29
29
|
- Updated dependencies [ac6cada1c]
|
|
@@ -51,8 +51,16 @@
|
|
|
51
51
|
- Updated dependencies [dca128112]
|
|
52
52
|
- Updated dependencies [9a7b0937]
|
|
53
53
|
- Updated dependencies [8853822c]
|
|
54
|
-
- @akinon/next@2.0.
|
|
55
|
-
- @akinon/theme-contract@2.0.
|
|
54
|
+
- @akinon/next@2.0.169-rc.0
|
|
55
|
+
- @akinon/theme-contract@2.0.169-rc.0
|
|
56
|
+
|
|
57
|
+
## 2.0.168
|
|
58
|
+
|
|
59
|
+
### Patch Changes
|
|
60
|
+
|
|
61
|
+
- c5aaa9b3: ZERO-5475: Let the account menu block render a designer-edited item list with a selected rule per item, and scroll sideways when horizontal
|
|
62
|
+
- @akinon/next@2.0.168
|
|
63
|
+
- @akinon/theme-contract@2.0.168
|
|
56
64
|
|
|
57
65
|
## 2.0.167
|
|
58
66
|
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.169-rc.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.
|
|
11
|
+
"@akinon/next": "2.0.169-rc.0",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.
|
|
16
|
+
"@akinon/theme-contract": "2.0.169-rc.0",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"react-multi-carousel": "2.8.4",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"zod": "4.4.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@akinon/next": "2.0.
|
|
24
|
+
"@akinon/next": "2.0.169-rc.0",
|
|
25
25
|
"@testing-library/jest-dom": "5.16.4",
|
|
26
26
|
"@testing-library/react": "16.3.2",
|
|
27
27
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZERO-5475: the account menu's items are a list the designer edits — added
|
|
3
|
+
* with +, removed with −, reordered — instead of five fixed keys.
|
|
4
|
+
*
|
|
5
|
+
* Every composition saved before the list existed has no `menuItems` and must
|
|
6
|
+
* render exactly as it did. Each entry can carry its own selected rule and
|
|
7
|
+
* further pages that keep it selected, and a horizontal menu can scroll
|
|
8
|
+
* sideways instead of wrapping.
|
|
9
|
+
*/
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { render } from '@testing-library/react';
|
|
12
|
+
|
|
13
|
+
let mockPathname = '/account';
|
|
14
|
+
|
|
15
|
+
jest.mock('next/navigation', () => ({
|
|
16
|
+
useRouter: () => ({ push: jest.fn(), replace: jest.fn() }),
|
|
17
|
+
usePathname: () => mockPathname,
|
|
18
|
+
useSearchParams: () => new URLSearchParams('')
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// Turkish is the hidden default, English lives under /en.
|
|
22
|
+
const TURKISH = {
|
|
23
|
+
locale: 'tr',
|
|
24
|
+
defaultLocaleValue: 'tr',
|
|
25
|
+
localeUrlStrategy: 'hide-default-locale'
|
|
26
|
+
};
|
|
27
|
+
let mockLocalization: Record<string, unknown> = { ...TURKISH };
|
|
28
|
+
|
|
29
|
+
jest.mock('@akinon/next/hooks', () => ({
|
|
30
|
+
__esModule: true,
|
|
31
|
+
useLocalization: () => ({ t: (path: string) => path, ...mockLocalization })
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
jest.mock('@akinon/next/utils', () => ({
|
|
35
|
+
__esModule: true,
|
|
36
|
+
...jest.requireActual('@akinon/next/utils'),
|
|
37
|
+
urlLocaleMatcherRegex: /^\/(en)(?=\/|$)/
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
// jest maps every `@akinon/next/data/client/*` to one module, so this mock
|
|
41
|
+
// stands in for `user` as well: the account menu needs its logout mutation.
|
|
42
|
+
jest.mock('@akinon/next/data/client/misc', () => ({
|
|
43
|
+
useLogoutMutation: () => [jest.fn(), { isLoading: false }]
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
import AccountMenuBlock from '../account-menu-block';
|
|
47
|
+
|
|
48
|
+
const mkBlock = (properties: Record<string, unknown> = {}) =>
|
|
49
|
+
({
|
|
50
|
+
id: 'account-menu-1',
|
|
51
|
+
type: 'account-menu',
|
|
52
|
+
label: 'account-menu',
|
|
53
|
+
order: 0,
|
|
54
|
+
hidden: false,
|
|
55
|
+
value: '',
|
|
56
|
+
properties,
|
|
57
|
+
styles: {},
|
|
58
|
+
blocks: []
|
|
59
|
+
} as never);
|
|
60
|
+
|
|
61
|
+
const blockProps = {
|
|
62
|
+
placeholderId: 'account-menu',
|
|
63
|
+
sectionId: 'account-menu-main',
|
|
64
|
+
currentBreakpoint: 'desktop',
|
|
65
|
+
isDesigner: false
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const renderMenu = (
|
|
69
|
+
properties: Record<string, unknown>,
|
|
70
|
+
overrides: Partial<typeof blockProps> = {}
|
|
71
|
+
) =>
|
|
72
|
+
render(
|
|
73
|
+
<AccountMenuBlock
|
|
74
|
+
block={mkBlock(properties)}
|
|
75
|
+
{...blockProps}
|
|
76
|
+
{...overrides}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const links = (root: ParentNode) =>
|
|
81
|
+
Array.from(root.querySelectorAll('a')).map((a) => ({
|
|
82
|
+
label: a.textContent,
|
|
83
|
+
href: a.getAttribute('href'),
|
|
84
|
+
key: a.getAttribute('data-account-menu-item')
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
const selectedLabels = (root: ParentNode) =>
|
|
88
|
+
Array.from(root.querySelectorAll('a[aria-current="page"]')).map(
|
|
89
|
+
(a) => a.textContent
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Gant's tab strip, the menu this list was built for.
|
|
93
|
+
const GANT_TABS = [
|
|
94
|
+
{ id: 'dashboard', label: 'Hesabım', href: '/account', match: 'exact' },
|
|
95
|
+
{
|
|
96
|
+
id: 'orders',
|
|
97
|
+
label: 'Siparişlerim',
|
|
98
|
+
href: '/users/orders',
|
|
99
|
+
match: 'startsWith',
|
|
100
|
+
activePaths: '/account/orders'
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'favourites',
|
|
104
|
+
label: 'Favorilerim',
|
|
105
|
+
href: '/account/favourite-products/',
|
|
106
|
+
match: 'startsWith'
|
|
107
|
+
},
|
|
108
|
+
{ id: 'loyalty', label: 'MyGANT', href: '/account/loyalty' },
|
|
109
|
+
{
|
|
110
|
+
id: 'personal',
|
|
111
|
+
label: 'Kişisel Bilgiler',
|
|
112
|
+
href: '/account/profile',
|
|
113
|
+
activePaths:
|
|
114
|
+
'/account/address, /account/change-email,\n/account/change-password'
|
|
115
|
+
},
|
|
116
|
+
{ id: 'coupons', label: 'Kuponlarım', href: '/account/coupons' }
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
mockLocalization = { ...TURKISH };
|
|
121
|
+
mockPathname = '/account';
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('account menu without an item list', () => {
|
|
125
|
+
it('renders the five fixed entries a published composition has always had', () => {
|
|
126
|
+
const { container } = renderMenu({});
|
|
127
|
+
|
|
128
|
+
expect(links(container)).toEqual([
|
|
129
|
+
{ label: 'Orders', href: '/account/orders', key: 'orders' },
|
|
130
|
+
{ label: 'Addresses', href: '/account/address', key: 'addresses' },
|
|
131
|
+
{ label: 'Profile', href: '/account/profile', key: 'profile' },
|
|
132
|
+
{
|
|
133
|
+
label: 'Favourites',
|
|
134
|
+
href: '/account/favourite-products',
|
|
135
|
+
key: 'favourites'
|
|
136
|
+
},
|
|
137
|
+
{ label: 'Coupons', href: '/account/coupons', key: 'coupons' }
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('still follows itemOrder and the per-key labels and links', () => {
|
|
142
|
+
const { container } = renderMenu({
|
|
143
|
+
itemOrder: 'coupons, orders',
|
|
144
|
+
ordersLabel: 'Siparişlerim',
|
|
145
|
+
ordersHref: '/users/orders'
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(links(container)).toEqual([
|
|
149
|
+
{ label: 'Coupons', href: '/account/coupons', key: 'coupons' },
|
|
150
|
+
{ label: 'Siparişlerim', href: '/users/orders', key: 'orders' }
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// A drop in the editor seeds `{desktop: undefined}` for a property without
|
|
155
|
+
// a default; the panel shows the five fixed entries for it, so must the page.
|
|
156
|
+
it.each([
|
|
157
|
+
['the bucket a drop seeds', { desktop: undefined }],
|
|
158
|
+
['that bucket once saved', {}],
|
|
159
|
+
['a blank value', '']
|
|
160
|
+
])('keeps the fixed entries for %s', (_case, menuItems) => {
|
|
161
|
+
const { container } = renderMenu({ menuItems });
|
|
162
|
+
|
|
163
|
+
expect(links(container).map((link) => link.key)).toEqual([
|
|
164
|
+
'orders',
|
|
165
|
+
'addresses',
|
|
166
|
+
'profile',
|
|
167
|
+
'favourites',
|
|
168
|
+
'coupons'
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('account menu with an item list', () => {
|
|
174
|
+
it('renders the listed entries in their order, ignoring the fixed keys', () => {
|
|
175
|
+
const { container } = renderMenu({
|
|
176
|
+
itemOrder: 'orders,addresses',
|
|
177
|
+
menuItems: GANT_TABS
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(
|
|
181
|
+
links(container).map(({ label, href, key }) => [label, href, key])
|
|
182
|
+
).toEqual([
|
|
183
|
+
['Hesabım', '/account', 'dashboard'],
|
|
184
|
+
['Siparişlerim', '/users/orders', 'orders'],
|
|
185
|
+
['Favorilerim', '/account/favourite-products/', 'favourites'],
|
|
186
|
+
['MyGANT', '/account/loyalty', 'loyalty'],
|
|
187
|
+
['Kişisel Bilgiler', '/account/profile', 'personal'],
|
|
188
|
+
['Kuponlarım', '/account/coupons', 'coupons']
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('skips an entry that has no label or no link yet', () => {
|
|
193
|
+
const { container } = renderMenu({
|
|
194
|
+
menuItems: [
|
|
195
|
+
{ id: 'a', label: 'Hesabım', href: '/account' },
|
|
196
|
+
{ id: 'b', label: '', href: '/account/orders' },
|
|
197
|
+
{ id: 'c', label: 'Kuponlarım', href: ' ' },
|
|
198
|
+
null
|
|
199
|
+
]
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(links(container).map((link) => link.label)).toEqual(['Hesabım']);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('shows no entry once every one was removed', () => {
|
|
206
|
+
const { container } = renderMenu({ menuItems: [], showLogout: true });
|
|
207
|
+
|
|
208
|
+
expect(links(container)).toEqual([]);
|
|
209
|
+
expect(
|
|
210
|
+
container.querySelector('[data-account-menu-item="logout"]')
|
|
211
|
+
).not.toBeNull();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('leaves the storefront empty when the list and logout are both off', () => {
|
|
215
|
+
const { container } = renderMenu({ menuItems: [], showLogout: false });
|
|
216
|
+
|
|
217
|
+
expect(container.innerHTML).toBe('');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('tells apart two entries that share an id', () => {
|
|
221
|
+
const { container } = renderMenu({
|
|
222
|
+
menuItems: [
|
|
223
|
+
{ id: 'tab', label: 'One', href: '/account/one' },
|
|
224
|
+
{ id: 'tab', label: 'Two', href: '/account/two' }
|
|
225
|
+
]
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(links(container).map((link) => link.key)).toEqual(['tab', 'tab-2']);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('reads a list authored per breakpoint', () => {
|
|
232
|
+
const { container } = renderMenu(
|
|
233
|
+
{
|
|
234
|
+
menuItems: {
|
|
235
|
+
desktop: GANT_TABS,
|
|
236
|
+
mobile: GANT_TABS.slice(0, 2)
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
{ currentBreakpoint: 'mobile' }
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(links(container).map((link) => link.label)).toEqual([
|
|
243
|
+
'Hesabım',
|
|
244
|
+
'Siparişlerim'
|
|
245
|
+
]);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('selected entry', () => {
|
|
250
|
+
it.each([
|
|
251
|
+
['/account', ['Hesabım']],
|
|
252
|
+
['/users/orders', ['Siparişlerim']],
|
|
253
|
+
['/account/orders/1234', ['Siparişlerim']],
|
|
254
|
+
['/account/favourite-products', ['Favorilerim']],
|
|
255
|
+
['/account/profile', ['Kişisel Bilgiler']],
|
|
256
|
+
['/account/change-password', ['Kişisel Bilgiler']],
|
|
257
|
+
['/account/change-email', ['Kişisel Bilgiler']],
|
|
258
|
+
['/account/address', ['Kişisel Bilgiler']],
|
|
259
|
+
['/account/coupons', ['Kuponlarım']]
|
|
260
|
+
])('on %s it is %j', (pathname, expected) => {
|
|
261
|
+
mockPathname = pathname;
|
|
262
|
+
const { container } = renderMenu({ menuItems: GANT_TABS });
|
|
263
|
+
|
|
264
|
+
expect(selectedLabels(container)).toEqual(expected);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('lets an entry without its own rule inherit the block setting', () => {
|
|
268
|
+
mockPathname = '/account/loyalty/history';
|
|
269
|
+
|
|
270
|
+
const exact = renderMenu({ menuItems: GANT_TABS });
|
|
271
|
+
expect(selectedLabels(exact.container)).toEqual([]);
|
|
272
|
+
exact.unmount();
|
|
273
|
+
|
|
274
|
+
const section = renderMenu({
|
|
275
|
+
menuItems: GANT_TABS,
|
|
276
|
+
activeMatch: 'startsWith'
|
|
277
|
+
});
|
|
278
|
+
// Hesabım says "only this page" itself, so the section rule leaves it out.
|
|
279
|
+
expect(selectedLabels(section.container)).toEqual(['MyGANT']);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('matches the further pages in the localized form the links render', () => {
|
|
283
|
+
mockLocalization = { ...TURKISH, locale: 'en' };
|
|
284
|
+
mockPathname = '/en/account/address';
|
|
285
|
+
const { container } = renderMenu({ menuItems: GANT_TABS });
|
|
286
|
+
|
|
287
|
+
expect(selectedLabels(container)).toEqual(['Kişisel Bilgiler']);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('horizontal overflow', () => {
|
|
292
|
+
const nav = (root: ParentNode) =>
|
|
293
|
+
root.querySelector('[data-account-menu]') as HTMLElement;
|
|
294
|
+
|
|
295
|
+
it('wraps by default, as a horizontal menu always has', () => {
|
|
296
|
+
const { container } = renderMenu({ orientation: 'horizontal' });
|
|
297
|
+
|
|
298
|
+
expect(nav(container).style.flexWrap).toBe('wrap');
|
|
299
|
+
expect(
|
|
300
|
+
nav(container).getAttribute('data-account-menu-overflow')
|
|
301
|
+
).toBeNull();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('stays on one line and scrolls sideways when set to scroll', () => {
|
|
305
|
+
const { container } = renderMenu({
|
|
306
|
+
orientation: 'horizontal',
|
|
307
|
+
overflow: 'scroll',
|
|
308
|
+
menuItems: GANT_TABS
|
|
309
|
+
});
|
|
310
|
+
const menu = nav(container);
|
|
311
|
+
|
|
312
|
+
expect(menu.getAttribute('data-account-menu-overflow')).toBe('scroll');
|
|
313
|
+
expect(menu.style.flexWrap).toBe('nowrap');
|
|
314
|
+
expect(menu.style.overflowX).toBe('auto');
|
|
315
|
+
expect(menu.style.whiteSpace).toBe('nowrap');
|
|
316
|
+
expect((container.querySelector('a') as HTMLElement).style.flexShrink).toBe(
|
|
317
|
+
'0'
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('ignores the setting on a vertical menu', () => {
|
|
322
|
+
const { container } = renderMenu({ overflow: 'scroll' });
|
|
323
|
+
const menu = nav(container);
|
|
324
|
+
|
|
325
|
+
expect(menu.style.flexDirection).toBe('column');
|
|
326
|
+
expect(menu.style.overflowX).toBe('');
|
|
327
|
+
expect(menu.getAttribute('data-account-menu-overflow')).toBeNull();
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -9,6 +9,7 @@ import { useLogoutMutation } from '@akinon/next/data/client/user';
|
|
|
9
9
|
import { emitCommerceEvent } from '../utils/analytics';
|
|
10
10
|
import { getResponsiveValue, partAttrs } from '../utils';
|
|
11
11
|
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
12
|
+
import { readResponsiveArray } from './responsive-array';
|
|
12
13
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -23,6 +24,56 @@ const DEFAULT_ITEM_ORDER = [
|
|
|
23
24
|
'coupons'
|
|
24
25
|
];
|
|
25
26
|
|
|
27
|
+
type ActiveMatch = 'exact' | 'startsWith';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One entry of the editor's item list (`menuItems`).
|
|
31
|
+
*
|
|
32
|
+
* `match` overrides the block-wide Active Item Match for this entry, and a
|
|
33
|
+
* blank one inherits it. `activePaths` names further pages that keep the entry
|
|
34
|
+
* selected — a "Personal details" tab that stays selected on the address and
|
|
35
|
+
* password pages — and each of them is matched with the entry's own rule.
|
|
36
|
+
*/
|
|
37
|
+
interface AccountMenuListEntry {
|
|
38
|
+
id?: unknown;
|
|
39
|
+
label?: unknown;
|
|
40
|
+
href?: unknown;
|
|
41
|
+
match?: unknown;
|
|
42
|
+
activePaths?: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ResolvedMenuItem {
|
|
46
|
+
key: string;
|
|
47
|
+
href: string;
|
|
48
|
+
label: string;
|
|
49
|
+
match: ActiveMatch;
|
|
50
|
+
activePaths: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const toActiveMatch = (value: unknown, fallback: ActiveMatch): ActiveMatch =>
|
|
54
|
+
value === 'exact' || value === 'startsWith' ? value : fallback;
|
|
55
|
+
|
|
56
|
+
const toText = (value: unknown) =>
|
|
57
|
+
typeof value === 'string' || typeof value === 'number'
|
|
58
|
+
? String(value).trim()
|
|
59
|
+
: '';
|
|
60
|
+
|
|
61
|
+
// Commas, spaces and line breaks all separate paths: a path never contains
|
|
62
|
+
// one, and the editor field is free text.
|
|
63
|
+
const toPathList = (value: unknown) =>
|
|
64
|
+
typeof value === 'string'
|
|
65
|
+
? value
|
|
66
|
+
.split(/[\s,]+/)
|
|
67
|
+
.map((path) => path.trim())
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
: [];
|
|
70
|
+
|
|
71
|
+
// `/account/orders/` and `/account/orders` are the same page; whether the
|
|
72
|
+
// pathname carries the slash depends on the brand's trailingSlash setting,
|
|
73
|
+
// and the configured href is typed by hand.
|
|
74
|
+
const trimTrailingSlash = (path: string) =>
|
|
75
|
+
path.length > 1 ? path.replace(/\/+$/, '') : path;
|
|
76
|
+
|
|
26
77
|
/**
|
|
27
78
|
* Functional account block: account area navigation.
|
|
28
79
|
*
|
|
@@ -30,9 +81,14 @@ const DEFAULT_ITEM_ORDER = [
|
|
|
30
81
|
* import the brand alias, so every target is an editor property. The
|
|
31
82
|
* defaults are the conventional '/account/...' paths — brands whose ROUTES
|
|
32
83
|
* differ (superstep uses '/users/orders' for orders) override them here.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
84
|
+
*
|
|
85
|
+
* Items come from `menuItems` — the list the designer adds to with + and
|
|
86
|
+
* removes from with − — whenever the block has one. Every composition saved
|
|
87
|
+
* before that list existed has none and keeps rendering from the five fixed
|
|
88
|
+
* keys: itemOrder, ordersHref, addressesHref, profileHref, favouritesHref,
|
|
89
|
+
* couponsHref and the matching *Label keys.
|
|
90
|
+
* Other properties: showLogout, orientation, overflow, activeMatch,
|
|
91
|
+
* emptyLabel, logoutLabel, logoutRedirect.
|
|
36
92
|
* Parts: menu, item (selected), logout, empty.
|
|
37
93
|
*/
|
|
38
94
|
const AccountMenuBlock = ({
|
|
@@ -63,74 +119,124 @@ const AccountMenuBlock = ({
|
|
|
63
119
|
const showLogout = boolProp('showLogout', true);
|
|
64
120
|
const orientation =
|
|
65
121
|
prop('orientation', 'vertical') === 'horizontal' ? 'horizontal' : 'vertical';
|
|
66
|
-
|
|
67
|
-
|
|
122
|
+
// A horizontal menu that does not fit either wraps onto a new line (what it
|
|
123
|
+
// has always done) or stays on one line and scrolls sideways — the tab strip
|
|
124
|
+
// most brands put above the account pages on a phone.
|
|
125
|
+
const scrollsSideways =
|
|
126
|
+
orientation === 'horizontal' && prop('overflow', 'wrap') === 'scroll';
|
|
127
|
+
const activeMatch = toActiveMatch(prop('activeMatch', 'exact'), 'exact');
|
|
68
128
|
const emptyLabel = prop('emptyLabel', '');
|
|
69
129
|
|
|
70
130
|
const pathname = usePathname();
|
|
71
131
|
const localize = useLocalizeHref();
|
|
72
132
|
const [logout, { isLoading: isLoggingOut }] = useLogoutMutation();
|
|
73
133
|
|
|
74
|
-
const
|
|
75
|
-
string,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
134
|
+
const legacyItems = (): ResolvedMenuItem[] => {
|
|
135
|
+
const definitions: Record<string, { href: string; label: string }> = {
|
|
136
|
+
orders: {
|
|
137
|
+
href: prop('ordersHref', '/account/orders'),
|
|
138
|
+
label: prop('ordersLabel', 'Orders')
|
|
139
|
+
},
|
|
140
|
+
addresses: {
|
|
141
|
+
href: prop('addressesHref', '/account/address'),
|
|
142
|
+
label: prop('addressesLabel', 'Addresses')
|
|
143
|
+
},
|
|
144
|
+
profile: {
|
|
145
|
+
href: prop('profileHref', '/account/profile'),
|
|
146
|
+
label: prop('profileLabel', 'Profile')
|
|
147
|
+
},
|
|
148
|
+
favourites: {
|
|
149
|
+
href: prop('favouritesHref', '/account/favourite-products'),
|
|
150
|
+
label: prop('favouritesLabel', 'Favourites')
|
|
151
|
+
},
|
|
152
|
+
coupons: {
|
|
153
|
+
href: prop('couponsHref', '/account/coupons'),
|
|
154
|
+
label: prop('couponsLabel', 'Coupons')
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const requestedOrder = prop('itemOrder', DEFAULT_ITEM_ORDER.join(','))
|
|
159
|
+
.split(',')
|
|
160
|
+
.map((key) => key.trim())
|
|
161
|
+
.filter(Boolean);
|
|
162
|
+
// A repeated key would render the same route twice and collide on the
|
|
163
|
+
// React key, so the list is de-duplicated before it is resolved.
|
|
164
|
+
const resolvedOrder = Array.from(
|
|
165
|
+
new Set(requestedOrder.length > 0 ? requestedOrder : DEFAULT_ITEM_ORDER)
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return resolvedOrder
|
|
169
|
+
.filter((key) => Boolean(definitions[key]))
|
|
170
|
+
.map((key) => ({
|
|
171
|
+
key,
|
|
172
|
+
href: definitions[key].href,
|
|
173
|
+
label: definitions[key].label,
|
|
174
|
+
match: activeMatch,
|
|
175
|
+
activePaths: []
|
|
176
|
+
}))
|
|
177
|
+
.filter((item) => Boolean(item.href) && Boolean(item.label));
|
|
103
178
|
};
|
|
104
179
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
180
|
+
const listItems = (rawList: unknown): ResolvedMenuItem[] => {
|
|
181
|
+
const seenKeys = new Set<string>();
|
|
182
|
+
|
|
183
|
+
return readResponsiveArray<AccountMenuListEntry | null>(
|
|
184
|
+
rawList,
|
|
185
|
+
currentBreakpoint
|
|
186
|
+
)
|
|
187
|
+
.map((entry, index) => {
|
|
188
|
+
const id = toText(entry?.id) || `item-${index + 1}`;
|
|
189
|
+
// Two entries sharing an id (one duplicated by hand in the JSON) would
|
|
190
|
+
// collide on the React key, so the second one is told apart.
|
|
191
|
+
const key = seenKeys.has(id) ? `${id}-${index + 1}` : id;
|
|
192
|
+
seenKeys.add(key);
|
|
114
193
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
194
|
+
return {
|
|
195
|
+
key,
|
|
196
|
+
href: toText(entry?.href),
|
|
197
|
+
label: toText(entry?.label),
|
|
198
|
+
match: toActiveMatch(entry?.match, activeMatch),
|
|
199
|
+
activePaths: toPathList(entry?.activePaths)
|
|
200
|
+
};
|
|
201
|
+
})
|
|
202
|
+
.filter((item) => Boolean(item.href) && Boolean(item.label));
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// A list is any array, flat or per breakpoint. An emptied list is still a
|
|
206
|
+
// list: the designer removed every entry, so the menu shows none. An object
|
|
207
|
+
// holding no array is not a list: a block dropped in the editor can carry
|
|
208
|
+
// `{desktop: undefined}` (`{}` once saved), and it must keep the five fixed
|
|
209
|
+
// entries the editor's panel shows for it, not render an empty menu.
|
|
210
|
+
const rawMenuItems = block.properties?.menuItems;
|
|
211
|
+
const hasItemList =
|
|
212
|
+
Array.isArray(rawMenuItems) ||
|
|
213
|
+
(rawMenuItems !== null &&
|
|
214
|
+
typeof rawMenuItems === 'object' &&
|
|
215
|
+
Object.values(rawMenuItems).some(Array.isArray));
|
|
216
|
+
|
|
217
|
+
const items = hasItemList ? listItems(rawMenuItems) : legacyItems();
|
|
118
218
|
|
|
119
219
|
// The pathname carries the locale (`/en/account/orders`) and the configured
|
|
120
220
|
// hrefs do not, so each item is matched in the localized form its link renders.
|
|
121
|
-
const
|
|
221
|
+
const matchesPage = (href: string, match: ActiveMatch) => {
|
|
122
222
|
if (!pathname || !href) return false;
|
|
123
|
-
const
|
|
124
|
-
|
|
223
|
+
const current = trimTrailingSlash(pathname);
|
|
224
|
+
const target = trimTrailingSlash(localize(href));
|
|
225
|
+
if (match === 'startsWith') {
|
|
125
226
|
return (
|
|
126
|
-
|
|
127
|
-
|
|
227
|
+
current === target ||
|
|
228
|
+
current.startsWith(`${target.replace(/\/$/, '')}/`)
|
|
128
229
|
);
|
|
129
230
|
}
|
|
130
231
|
|
|
131
|
-
return
|
|
232
|
+
return current === target;
|
|
132
233
|
};
|
|
133
234
|
|
|
235
|
+
const isActive = (item: ResolvedMenuItem) =>
|
|
236
|
+
[item.href, ...item.activePaths].some((href) =>
|
|
237
|
+
matchesPage(href, item.match)
|
|
238
|
+
);
|
|
239
|
+
|
|
134
240
|
const handleLogout = async () => {
|
|
135
241
|
if (isDesigner) return;
|
|
136
242
|
|
|
@@ -189,7 +295,10 @@ const AccountMenuBlock = ({
|
|
|
189
295
|
padding: '8px 0',
|
|
190
296
|
color: 'inherit',
|
|
191
297
|
textDecoration: 'none',
|
|
192
|
-
fontWeight: active ? 600 : 400
|
|
298
|
+
fontWeight: active ? 600 : 400,
|
|
299
|
+
// On a scrolling strip an entry keeps its full width instead of being
|
|
300
|
+
// squeezed until its label breaks.
|
|
301
|
+
...(scrollsSideways ? { flexShrink: 0 } : null)
|
|
193
302
|
});
|
|
194
303
|
|
|
195
304
|
// Both spellings of each field are published so a designer can bind either
|
|
@@ -201,7 +310,7 @@ const AccountMenuBlock = ({
|
|
|
201
310
|
label: item.label,
|
|
202
311
|
href: item.href,
|
|
203
312
|
url: item.href,
|
|
204
|
-
active: isActive(item
|
|
313
|
+
active: isActive(item)
|
|
205
314
|
}));
|
|
206
315
|
|
|
207
316
|
// A designer who marked a child as the row layout gets that layout repeated
|
|
@@ -219,21 +328,34 @@ const AccountMenuBlock = ({
|
|
|
219
328
|
/>
|
|
220
329
|
) : null;
|
|
221
330
|
|
|
331
|
+
const horizontalLayout: React.CSSProperties = scrollsSideways
|
|
332
|
+
? {
|
|
333
|
+
flexWrap: 'nowrap',
|
|
334
|
+
overflowX: 'auto',
|
|
335
|
+
// `nowrap` is inherited by row layouts too, so a templated row is as
|
|
336
|
+
// wide as its content and the strip scrolls instead of squeezing it.
|
|
337
|
+
whiteSpace: 'nowrap',
|
|
338
|
+
scrollbarWidth: 'none',
|
|
339
|
+
WebkitOverflowScrolling: 'touch'
|
|
340
|
+
}
|
|
341
|
+
: { flexWrap: 'wrap' };
|
|
342
|
+
|
|
222
343
|
return (
|
|
223
344
|
<nav
|
|
224
345
|
data-account-menu=""
|
|
225
346
|
data-account-menu-orientation={orientation}
|
|
347
|
+
data-account-menu-overflow={scrollsSideways ? 'scroll' : undefined}
|
|
226
348
|
{...partAttrs('menu')}
|
|
227
349
|
style={{
|
|
228
350
|
display: 'flex',
|
|
229
351
|
flexDirection: orientation === 'horizontal' ? 'row' : 'column',
|
|
230
|
-
...(orientation === 'horizontal' ?
|
|
352
|
+
...(orientation === 'horizontal' ? horizontalLayout : null),
|
|
231
353
|
gap: 'var(--account-menu-gap, 4px)'
|
|
232
354
|
}}
|
|
233
355
|
>
|
|
234
356
|
{customItems ??
|
|
235
357
|
items.map((item) => {
|
|
236
|
-
const active = isActive(item
|
|
358
|
+
const active = isActive(item);
|
|
237
359
|
|
|
238
360
|
return (
|
|
239
361
|
<NextLink
|