@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.168-rc.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 [d635d345]
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.168-rc.0
55
- - @akinon/theme-contract@2.0.168-rc.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.168-rc.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.168-rc.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.168-rc.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.168-rc.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
- * Properties: showLogout, itemOrder, orientation, activeMatch, emptyLabel,
34
- * ordersHref, addressesHref, profileHref, favouritesHref, couponsHref,
35
- * logoutLabel and the matching *Label keys.
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
- const activeMatch =
67
- prop('activeMatch', 'exact') === 'startsWith' ? 'startsWith' : 'exact';
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 definitions: Record<
75
- string,
76
- { key: string; href: string; label: string }
77
- > = {
78
- orders: {
79
- key: 'orders',
80
- href: prop('ordersHref', '/account/orders'),
81
- label: prop('ordersLabel', 'Orders')
82
- },
83
- addresses: {
84
- key: 'addresses',
85
- href: prop('addressesHref', '/account/address'),
86
- label: prop('addressesLabel', 'Addresses')
87
- },
88
- profile: {
89
- key: 'profile',
90
- href: prop('profileHref', '/account/profile'),
91
- label: prop('profileLabel', 'Profile')
92
- },
93
- favourites: {
94
- key: 'favourites',
95
- href: prop('favouritesHref', '/account/favourite-products'),
96
- label: prop('favouritesLabel', 'Favourites')
97
- },
98
- coupons: {
99
- key: 'coupons',
100
- href: prop('couponsHref', '/account/coupons'),
101
- label: prop('couponsLabel', 'Coupons')
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 requestedOrder = prop('itemOrder', DEFAULT_ITEM_ORDER.join(','))
106
- .split(',')
107
- .map((key) => key.trim())
108
- .filter(Boolean);
109
- // A repeated key would render the same route twice and collide on the React
110
- // key, so the list is de-duplicated before it is resolved.
111
- const resolvedOrder = Array.from(
112
- new Set(requestedOrder.length > 0 ? requestedOrder : DEFAULT_ITEM_ORDER)
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
- const items = resolvedOrder
116
- .map((key) => definitions[key])
117
- .filter((item) => Boolean(item?.href) && Boolean(item?.label));
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 isActive = (href: string) => {
221
+ const matchesPage = (href: string, match: ActiveMatch) => {
122
222
  if (!pathname || !href) return false;
123
- const target = localize(href);
124
- if (activeMatch === 'startsWith') {
223
+ const current = trimTrailingSlash(pathname);
224
+ const target = trimTrailingSlash(localize(href));
225
+ if (match === 'startsWith') {
125
226
  return (
126
- pathname === target ||
127
- pathname.startsWith(`${target.replace(/\/$/, '')}/`)
227
+ current === target ||
228
+ current.startsWith(`${target.replace(/\/$/, '')}/`)
128
229
  );
129
230
  }
130
231
 
131
- return pathname === target;
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.href)
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' ? { flexWrap: 'wrap' } : null),
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.href);
358
+ const active = isActive(item);
237
359
 
238
360
  return (
239
361
  <NextLink