@akinon/pz-theme 2.0.117-rc.0 → 2.0.118-beta.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +15 -35
  2. package/package.json +4 -4
  3. package/src/blocks/__tests__/locale-links.test.tsx +429 -0
  4. package/src/blocks/account-button-block.tsx +1 -1
  5. package/src/blocks/account-menu-block.tsx +9 -3
  6. package/src/blocks/applied-filters-block.tsx +7 -1
  7. package/src/blocks/auth-form-block.tsx +7 -4
  8. package/src/blocks/basket-items-block.tsx +1 -1
  9. package/src/blocks/basket-summary-block.tsx +1 -1
  10. package/src/blocks/button-block.tsx +5 -1
  11. package/src/blocks/carousel-item-block.tsx +1 -1
  12. package/src/blocks/coupon-wallet-block.tsx +3 -1
  13. package/src/blocks/facet-filters-block.tsx +14 -10
  14. package/src/blocks/favorite-button-block.tsx +1 -1
  15. package/src/blocks/favourites-grid-block.tsx +1 -1
  16. package/src/blocks/group-block.tsx +19 -6
  17. package/src/blocks/hamburger-menu-block.tsx +1 -1
  18. package/src/blocks/hotspot-block.tsx +3 -1
  19. package/src/blocks/hover-showcase-item-block.tsx +3 -1
  20. package/src/blocks/link-block.tsx +5 -22
  21. package/src/blocks/listing-breadcrumb-block.tsx +1 -1
  22. package/src/blocks/listing-pagination-block.tsx +11 -3
  23. package/src/blocks/mega-menu-block.tsx +9 -3
  24. package/src/blocks/mini-basket-block.tsx +1 -1
  25. package/src/blocks/nav-menu-block.tsx +9 -3
  26. package/src/blocks/oauth-buttons-block.tsx +4 -0
  27. package/src/blocks/order-detail-block.tsx +1 -1
  28. package/src/blocks/order-list-block.tsx +1 -1
  29. package/src/blocks/order-success-block.tsx +1 -1
  30. package/src/blocks/otp-form-block.tsx +4 -1
  31. package/src/blocks/payment-badges-block.tsx +1 -1
  32. package/src/blocks/product-breadcrumb-block.tsx +1 -1
  33. package/src/blocks/product-grid-block.tsx +1 -1
  34. package/src/blocks/product-recommendations-block.tsx +1 -1
  35. package/src/blocks/register-form-block.tsx +6 -3
  36. package/src/blocks/search-box-block.tsx +7 -1
  37. package/src/blocks/social-icons-list-block.tsx +1 -1
  38. package/src/blocks/social-links-block.tsx +1 -1
  39. package/src/blocks/sort-select-block.tsx +8 -2
  40. package/src/blocks/store-locator-block.tsx +3 -1
  41. package/src/blocks/text-block.tsx +8 -1
  42. package/src/blocks/variant-selector-block.tsx +6 -2
  43. package/src/blocks/wishlist-button-block.tsx +1 -1
  44. package/src/components/__tests__/theme-link.test.tsx +169 -0
  45. package/src/components/theme-link.tsx +36 -0
  46. package/src/custom/README-sections.md +6 -0
  47. package/src/custom/README.md +71 -0
  48. package/src/custom/__tests__/custom-entry-exports.test.tsx +13 -0
  49. package/src/custom/__tests__/theme-editable.test.tsx +179 -0
  50. package/src/custom/__tests__/use-theme-setting.test.tsx +101 -0
  51. package/src/custom/index.ts +16 -0
  52. package/src/custom/theme-editable.tsx +57 -4
  53. package/src/custom/use-theme-setting.ts +77 -10
  54. package/src/hooks/use-localize-href.ts +34 -0
  55. package/src/index.ts +12 -0
  56. package/src/sections/find-in-store-section.tsx +3 -1
  57. package/src/sections/hover-showcase-section.tsx +6 -2
  58. package/src/sections/installment-options-section.tsx +3 -1
  59. package/src/sections/slider-quick-view.tsx +1 -1
  60. package/src/utils/__tests__/localize-href.test.ts +328 -0
  61. package/src/utils/localize-href.ts +192 -0
package/CHANGELOG.md CHANGED
@@ -1,40 +1,20 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.117-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b1]
34
- - Updated dependencies [1595d45]
35
- - Updated dependencies [dca1281]
36
- - @akinon/next@2.0.117-rc.0
37
- - @akinon/theme-contract@2.0.117-rc.0
3
+ ## 2.0.118-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.118-beta.0
9
+ - @akinon/theme-contract@2.0.118-beta.0
10
+
11
+ ## 2.0.117
12
+
13
+ ### Patch Changes
14
+
15
+ - bed36c7: ZERO-5100: Keep theme links in the language of the page they are on. Block links, router navigations and authored rich text now carry the active locale, so an English page no longer prefetches Turkish URLs and turns the shopper's session Turkish. Full URLs to the same storefront count as paths; the API, Next's assets and file addresses are left alone. Custom sections get ThemeLink and useLocalizeHref from @akinon/pz-theme/src/custom, ThemeEditable localizes the href of the anchor it wraps, and both ThemeEditable and useThemeSetting accept authored text written per language.
16
+ - @akinon/next@2.0.117
17
+ - @akinon/theme-contract@2.0.117
38
18
 
39
19
  ## 2.0.116
40
20
 
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.117-rc.0",
4
+ "version": "2.0.118-beta.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.117-rc.0",
11
+ "@akinon/next": "2.0.118-beta.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.117-rc.0",
16
+ "@akinon/theme-contract": "2.0.118-beta.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.117-rc.0",
24
+ "@akinon/next": "2.0.118-beta.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,429 @@
1
+ /**
2
+ * ZERO-5100: on an English page every internal link a block renders — and
3
+ * every navigation it makes itself — carries `/en`.
4
+ *
5
+ * Content stores storefront paths without a locale, so these used to point at
6
+ * the Turkish site: a click left the English site, and in production the
7
+ * prefetch of each unprefixed link made the middleware set `pz-locale=tr`,
8
+ * turning every later client request Turkish while the page stayed English.
9
+ */
10
+ import React from 'react';
11
+ import { fireEvent, render } from '@testing-library/react';
12
+ import { renderToString } from 'react-dom/server';
13
+
14
+ const mockPush = jest.fn();
15
+ let mockPathname = '/en/';
16
+
17
+ jest.mock('next/navigation', () => ({
18
+ useRouter: () => ({ push: mockPush, replace: jest.fn() }),
19
+ usePathname: () => mockPathname,
20
+ useSearchParams: () => new URLSearchParams('')
21
+ }));
22
+
23
+ // Turkish is the hidden default, English lives under /en — zsa-zsa-zsu's setup.
24
+ const TURKISH = {
25
+ locale: 'tr',
26
+ defaultLocaleValue: 'tr',
27
+ localeUrlStrategy: 'hide-default-locale'
28
+ };
29
+ let mockLocalization: Record<string, unknown> = { ...TURKISH };
30
+
31
+ jest.mock('@akinon/next/hooks', () => ({
32
+ __esModule: true,
33
+ useLocalization: () => ({ t: (path: string) => path, ...mockLocalization })
34
+ }));
35
+
36
+ jest.mock('@akinon/next/utils', () => ({
37
+ __esModule: true,
38
+ ...jest.requireActual('@akinon/next/utils'),
39
+ urlLocaleMatcherRegex: /^\/(en)(?=\/|$)/
40
+ }));
41
+
42
+ const mockMenu = [
43
+ {
44
+ pk: 1,
45
+ label: 'Bedroom',
46
+ url: '/yatak-odasi/',
47
+ children: [
48
+ { pk: 2, label: 'Beds', url: '/yatak-odasi/yataklar/', children: [] }
49
+ ]
50
+ },
51
+ {
52
+ pk: 3,
53
+ label: 'Lookbook',
54
+ url: 'https://lookbook.example.com/',
55
+ children: []
56
+ }
57
+ ];
58
+
59
+ // jest maps every `@akinon/next/data/client/*` to one module, so this mock
60
+ // stands in for `user` as well: the account menu needs its logout mutation.
61
+ jest.mock('@akinon/next/data/client/misc', () => ({
62
+ useGetMenuQuery: () => ({ data: mockMenu, isLoading: false }),
63
+ useLogoutMutation: () => [jest.fn(), { isLoading: false }]
64
+ }));
65
+
66
+ import NavMenuBlock from '../nav-menu-block';
67
+ import SearchBoxBlock from '../search-box-block';
68
+ import AccountMenuBlock from '../account-menu-block';
69
+ import TextBlock from '../text-block';
70
+ import GroupBlock from '../group-block';
71
+ import HoverShowcaseSection from '../../sections/hover-showcase-section';
72
+
73
+ const mkBlock = (type: string, properties: Record<string, unknown> = {}) =>
74
+ ({
75
+ id: `${type}-1`,
76
+ type,
77
+ label: type,
78
+ order: 0,
79
+ hidden: false,
80
+ value: '',
81
+ properties,
82
+ styles: {},
83
+ blocks: []
84
+ } as never);
85
+
86
+ const blockProps = {
87
+ placeholderId: 'header',
88
+ sectionId: 'header-main',
89
+ currentBreakpoint: 'desktop',
90
+ isDesigner: false
91
+ };
92
+
93
+ const navHrefs = (root: ParentNode) =>
94
+ Array.from(root.querySelectorAll('a')).map((a) => a.getAttribute('href'));
95
+
96
+ beforeEach(() => {
97
+ mockLocalization = { ...TURKISH };
98
+ mockPathname = '/en/';
99
+ mockPush.mockClear();
100
+ });
101
+
102
+ describe('nav-menu links', () => {
103
+ it('carry /en on an English page, in the server HTML too', () => {
104
+ mockLocalization = { ...TURKISH, locale: 'en' };
105
+ const nav = mkBlock('nav-menu', { showSubmenu: true });
106
+
107
+ const host = document.createElement('div');
108
+ host.innerHTML = renderToString(
109
+ <NavMenuBlock block={nav} {...blockProps} />
110
+ );
111
+
112
+ expect(navHrefs(host)).toContain('/en/yatak-odasi/');
113
+ expect(navHrefs(host)).not.toContain('/yatak-odasi/');
114
+ // Somewhere else entirely: no locale to add.
115
+ expect(navHrefs(host)).toContain('https://lookbook.example.com/');
116
+ });
117
+
118
+ it('stay unprefixed on the default locale', () => {
119
+ const { container } = render(
120
+ <NavMenuBlock block={mkBlock('nav-menu')} {...blockProps} />
121
+ );
122
+
123
+ expect(navHrefs(container)).toContain('/yatak-odasi/');
124
+ expect(navHrefs(container)).not.toContain('/en/yatak-odasi/');
125
+ });
126
+ });
127
+
128
+ describe('search submit', () => {
129
+ const submitSearch = () => {
130
+ const { container } = render(
131
+ <SearchBoxBlock
132
+ block={mkBlock('search-box', { mode: 'inline' })}
133
+ {...blockProps}
134
+ />
135
+ );
136
+ const input = container.querySelector(
137
+ '[data-search-input]'
138
+ ) as HTMLInputElement;
139
+
140
+ fireEvent.change(input, { target: { value: 'yastık' } });
141
+ fireEvent.submit(container.querySelector('form') as HTMLFormElement);
142
+ };
143
+
144
+ it('lands on the English results from an English page', () => {
145
+ mockLocalization = { ...TURKISH, locale: 'en' };
146
+
147
+ submitSearch();
148
+
149
+ expect(mockPush).toHaveBeenCalledWith(
150
+ `/en/list?search_text=${encodeURIComponent('yastık')}`
151
+ );
152
+ });
153
+
154
+ it('keeps the authored path on the default locale', () => {
155
+ submitSearch();
156
+
157
+ expect(mockPush).toHaveBeenCalledWith(
158
+ `/list?search_text=${encodeURIComponent('yastık')}`
159
+ );
160
+ });
161
+ });
162
+
163
+ // The pathname carries `/en` while the stored URLs do not, so the current
164
+ // entry has to be matched in its localized form.
165
+ describe('current-page highlighting on an English page', () => {
166
+ const currentHrefs = (root: ParentNode) =>
167
+ Array.from(root.querySelectorAll('a[aria-current="page"]')).map((a) =>
168
+ a.getAttribute('href')
169
+ );
170
+
171
+ it('marks the nav-menu entry of the open category', () => {
172
+ mockLocalization = { ...TURKISH, locale: 'en' };
173
+ mockPathname = '/en/yatak-odasi';
174
+
175
+ const { container } = render(
176
+ <NavMenuBlock block={mkBlock('nav-menu')} {...blockProps} />
177
+ );
178
+
179
+ expect(currentHrefs(container)).toEqual(['/en/yatak-odasi/']);
180
+ });
181
+
182
+ it('keeps marking it on the default locale', () => {
183
+ mockPathname = '/yatak-odasi';
184
+
185
+ const { container } = render(
186
+ <NavMenuBlock block={mkBlock('nav-menu')} {...blockProps} />
187
+ );
188
+
189
+ expect(currentHrefs(container)).toEqual(['/yatak-odasi/']);
190
+ });
191
+
192
+ it('never widens a section match to the English home entry', () => {
193
+ mockLocalization = { ...TURKISH, locale: 'en' };
194
+ mockPathname = '/en/yatak-odasi/yataklar';
195
+ const withHome = mockMenu.slice();
196
+ mockMenu.unshift({ pk: 9, label: 'Home', url: '/', children: [] });
197
+
198
+ try {
199
+ const { container } = render(
200
+ <NavMenuBlock
201
+ block={mkBlock('nav-menu', { activeMatch: 'section' })}
202
+ {...blockProps}
203
+ />
204
+ );
205
+
206
+ expect(currentHrefs(container)).toEqual(['/en/yatak-odasi/']);
207
+ } finally {
208
+ mockMenu.splice(0, mockMenu.length, ...withHome);
209
+ }
210
+ });
211
+
212
+ it('marks the account-menu entry of the open account page', () => {
213
+ mockLocalization = { ...TURKISH, locale: 'en' };
214
+ mockPathname = '/en/account/orders';
215
+
216
+ const { container } = render(
217
+ <AccountMenuBlock block={mkBlock('account-menu')} {...blockProps} />
218
+ );
219
+
220
+ expect(currentHrefs(container)).toEqual(['/en/account/orders']);
221
+ });
222
+ });
223
+
224
+ describe('links drawn outside ThemeLink', () => {
225
+ it('prefixes the links inside authored rich text', () => {
226
+ mockLocalization = { ...TURKISH, locale: 'en' };
227
+ const text = {
228
+ ...(mkBlock('text') as object),
229
+ value: 'Detaylar için <a href="/kampanya/">tıklayın</a>'
230
+ } as never;
231
+
232
+ const host = document.createElement('div');
233
+ host.innerHTML = renderToString(<TextBlock block={text} {...blockProps} />);
234
+
235
+ expect(navHrefs(host)).toEqual(['/en/kampanya/']);
236
+ });
237
+
238
+ it('prefixes the hover-showcase item links the section draws itself', () => {
239
+ mockLocalization = { ...TURKISH, locale: 'en' };
240
+ const item = {
241
+ ...(mkBlock('hover-showcase-item', {
242
+ title: 'Bedroom',
243
+ link: '/yatak-odasi/'
244
+ }) as object)
245
+ };
246
+
247
+ const host = document.createElement('div');
248
+ host.innerHTML = renderToString(
249
+ <HoverShowcaseSection
250
+ section={
251
+ {
252
+ id: 'showcase',
253
+ type: 'hover-showcase',
254
+ label: 'showcase',
255
+ styles: {},
256
+ blocks: [item]
257
+ } as never
258
+ }
259
+ currentBreakpoint="desktop"
260
+ placeholderId="home"
261
+ isDesigner={false}
262
+ />
263
+ );
264
+
265
+ const hrefs = navHrefs(host);
266
+ expect(hrefs.length).toBeGreaterThan(0);
267
+ expect(new Set(hrefs)).toEqual(new Set(['/en/yatak-odasi/']));
268
+ });
269
+ });
270
+
271
+ /**
272
+ * A composed anchor is a group with `tag: 'a'`. Its href decides whether the
273
+ * click goes through the router — and the router link is the one that carries
274
+ * the locale, so an href that misses that branch loses `/en` with it.
275
+ */
276
+ describe('group anchors', () => {
277
+ const groupAnchor = (href: string) =>
278
+ ({
279
+ ...(mkBlock('group', { tag: 'a', href }) as object)
280
+ } as never);
281
+
282
+ it('route a pasted path with its spaces still on it', () => {
283
+ mockLocalization = { ...TURKISH, locale: 'en' };
284
+
285
+ const { container } = render(
286
+ <GroupBlock block={groupAnchor(' /yatak-odasi/ ')} {...blockProps} />
287
+ );
288
+
289
+ expect(navHrefs(container)).toEqual(['/en/yatak-odasi/']);
290
+ });
291
+
292
+ it('keep that path unprefixed, and trimmed, on the default locale', () => {
293
+ const { container } = render(
294
+ <GroupBlock block={groupAnchor(' /yatak-odasi/ ')} {...blockProps} />
295
+ );
296
+
297
+ expect(navHrefs(container)).toEqual(['/yatak-odasi/']);
298
+ });
299
+
300
+ it('leave an address somewhere else on its plain anchor', () => {
301
+ mockLocalization = { ...TURKISH, locale: 'en' };
302
+
303
+ const { container } = render(
304
+ <GroupBlock
305
+ block={groupAnchor('https://lookbook.example.com/')}
306
+ {...blockProps}
307
+ />
308
+ );
309
+
310
+ expect(navHrefs(container)).toEqual(['https://lookbook.example.com/']);
311
+ });
312
+
313
+ /**
314
+ * A full URL of this storefront's own is one of its routes too, which is
315
+ * what `link-block` already made of it. Without this the merchant's pasted
316
+ * address bar fell to the plain anchor, and the click left the English site.
317
+ */
318
+ describe('with a full URL of this storefront', () => {
319
+ const configured = process.env.NEXT_PUBLIC_URL;
320
+
321
+ beforeEach(() => {
322
+ process.env.NEXT_PUBLIC_URL = 'https://www.zsazsazsu.com.tr';
323
+ });
324
+
325
+ afterAll(() => {
326
+ process.env.NEXT_PUBLIC_URL = configured;
327
+ });
328
+
329
+ it('routes it as the localized path on an English page', () => {
330
+ mockLocalization = { ...TURKISH, locale: 'en' };
331
+
332
+ const { container } = render(
333
+ <GroupBlock
334
+ block={groupAnchor('https://zsazsazsu.com.tr/yeni-urunler/')}
335
+ {...blockProps}
336
+ />
337
+ );
338
+
339
+ expect(navHrefs(container)).toEqual(['/en/yeni-urunler/']);
340
+ });
341
+
342
+ it('routes it as the bare path on the default locale', () => {
343
+ const { container } = render(
344
+ <GroupBlock
345
+ block={groupAnchor('https://www.zsazsazsu.com.tr/yeni-urunler/')}
346
+ {...blockProps}
347
+ />
348
+ );
349
+
350
+ expect(navHrefs(container)).toEqual(['/yeni-urunler/']);
351
+ });
352
+
353
+ // A file keeps the path it points at and gains no locale, exactly as a
354
+ // root-relative '/files/kvkk.pdf' has always been treated here.
355
+ it('adds no locale to a file on this storefront', () => {
356
+ mockLocalization = { ...TURKISH, locale: 'en' };
357
+
358
+ const { container } = render(
359
+ <GroupBlock
360
+ block={groupAnchor('https://www.zsazsazsu.com.tr/files/kvkk.pdf')}
361
+ {...blockProps}
362
+ />
363
+ );
364
+
365
+ expect(navHrefs(container)).toEqual(['/files/kvkk.pdf']);
366
+ });
367
+ });
368
+ });
369
+
370
+ /**
371
+ * Merchants paste the address bar rather than typing a path: the live home
372
+ * composition stores 42 of its links as full URLs to this same site. In
373
+ * production Next prefetches those as internal routes, and the middleware
374
+ * answers the unprefixed path with `pz-locale=tr`.
375
+ */
376
+ describe('full URLs to this storefront', () => {
377
+ const configured = process.env.NEXT_PUBLIC_URL;
378
+
379
+ beforeEach(() => {
380
+ process.env.NEXT_PUBLIC_URL = 'https://www.zsazsazsu.com.tr';
381
+ });
382
+
383
+ afterAll(() => {
384
+ process.env.NEXT_PUBLIC_URL = configured;
385
+ });
386
+
387
+ const richText = (markup: string) =>
388
+ ({
389
+ ...(mkBlock('text') as object),
390
+ value: markup
391
+ } as never);
392
+
393
+ const markup =
394
+ '<a href="https://www.zsazsazsu.com.tr/kampanya/">kampanya</a>' +
395
+ '<a href="https://zsazsazsu.com.tr/yeni-urunler/">yeni</a>' +
396
+ '<a href="https://www.zsazsazsu.com.tr/files/kvkk.pdf">kvkk</a>' +
397
+ '<a href="https://lookbook.example.com/">lookbook</a>';
398
+
399
+ it('become localized paths in authored rich text', () => {
400
+ mockLocalization = { ...TURKISH, locale: 'en' };
401
+
402
+ const host = document.createElement('div');
403
+ host.innerHTML = renderToString(
404
+ <TextBlock block={richText(markup)} {...blockProps} />
405
+ );
406
+
407
+ expect(navHrefs(host)).toEqual([
408
+ '/en/kampanya/',
409
+ '/en/yeni-urunler/',
410
+ // No localized copy of a file, the same as `/manifest.json`.
411
+ 'https://www.zsazsazsu.com.tr/files/kvkk.pdf',
412
+ 'https://lookbook.example.com/'
413
+ ]);
414
+ });
415
+
416
+ it('stay byte for byte as the merchant wrote them on the Turkish page', () => {
417
+ const host = document.createElement('div');
418
+ host.innerHTML = renderToString(
419
+ <TextBlock block={richText(markup)} {...blockProps} />
420
+ );
421
+
422
+ expect(navHrefs(host)).toEqual([
423
+ 'https://www.zsazsazsu.com.tr/kampanya/',
424
+ 'https://zsazsazsu.com.tr/yeni-urunler/',
425
+ 'https://www.zsazsazsu.com.tr/files/kvkk.pdf',
426
+ 'https://lookbook.example.com/'
427
+ ]);
428
+ });
429
+ });
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import React from 'react';
4
- import NextLink from 'next/link';
4
+ import { ThemeLink as NextLink } from '../components/theme-link';
5
5
  import { useSession } from 'next-auth/react';
6
6
  import { usePathname } from 'next/navigation';
7
7
  import { getResponsiveValue, partAttrs } from '../utils';
@@ -1,8 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import React from 'react';
4
- import NextLink from 'next/link';
4
+ import { ThemeLink as NextLink } from '../components/theme-link';
5
5
  import { usePathname } from 'next/navigation';
6
+ import { useLocalizeHref } from '../hooks/use-localize-href';
6
7
  import { signOut } from 'next-auth/react';
7
8
  import { useLogoutMutation } from '@akinon/next/data/client/user';
8
9
  import { emitCommerceEvent } from '../utils/analytics';
@@ -67,6 +68,7 @@ const AccountMenuBlock = ({
67
68
  const emptyLabel = prop('emptyLabel', '');
68
69
 
69
70
  const pathname = usePathname();
71
+ const localize = useLocalizeHref();
70
72
  const [logout, { isLoading: isLoggingOut }] = useLogoutMutation();
71
73
 
72
74
  const definitions: Record<
@@ -114,15 +116,19 @@ const AccountMenuBlock = ({
114
116
  .map((key) => definitions[key])
115
117
  .filter((item) => Boolean(item?.href) && Boolean(item?.label));
116
118
 
119
+ // The pathname carries the locale (`/en/account/orders`) and the configured
120
+ // hrefs do not, so each item is matched in the localized form its link renders.
117
121
  const isActive = (href: string) => {
118
122
  if (!pathname || !href) return false;
123
+ const target = localize(href);
119
124
  if (activeMatch === 'startsWith') {
120
125
  return (
121
- pathname === href || pathname.startsWith(`${href.replace(/\/$/, '')}/`)
126
+ pathname === target ||
127
+ pathname.startsWith(`${target.replace(/\/$/, '')}/`)
122
128
  );
123
129
  }
124
130
 
125
- return pathname === href;
131
+ return pathname === target;
126
132
  };
127
133
 
128
134
  const handleLogout = async () => {
@@ -3,6 +3,7 @@
3
3
  import React from 'react';
4
4
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
5
5
  import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { useLocalizeHref } from '../hooks/use-localize-href';
6
7
  import { hasItemTemplate, ItemTemplateList } from './item-template';
7
8
  import { BlockRendererProps } from './block-renderer-registry';
8
9
  import {
@@ -48,6 +49,7 @@ const AppliedFiltersBlock = ({
48
49
  const router = useRouter();
49
50
  const pathname = usePathname();
50
51
  const searchParams = useSearchParams();
52
+ const localize = useLocalizeHref();
51
53
 
52
54
  const showClearAll = readToggle(
53
55
  block.properties?.showClearAll,
@@ -121,7 +123,11 @@ const AppliedFiltersBlock = ({
121
123
  const hrefForParams = (params: URLSearchParams) => {
122
124
  // Any filter change invalidates the current page cursor.
123
125
  params.delete(pageQueryParam);
124
- return buildListingHref(pathname || listing?.path || '', params);
126
+ // Commerce's `listing.path` has no locale; `localize` adds the page's (and
127
+ // leaves the one a `pathname` already carries).
128
+ return localize(
129
+ buildListingHref(pathname || listing?.path || '', params)
130
+ );
125
131
  };
126
132
 
127
133
  /**
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useContext, useRef, useState } from 'react';
4
- import NextLink from 'next/link';
4
+ import { ThemeLink as NextLink } from '../components/theme-link';
5
5
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
6
6
  import { signIn, useSession } from 'next-auth/react';
7
7
  import { useLocalization } from '@akinon/next/hooks';
8
8
  import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
9
+ import { useLocalizeHref } from '../hooks/use-localize-href';
9
10
  import {
10
11
  AuthFieldErrors,
11
12
  AuthFieldErrorsProvider,
@@ -298,6 +299,7 @@ const AuthFormBlock = ({
298
299
  );
299
300
 
300
301
  const router = useRouter();
302
+ const localize = useLocalizeHref();
301
303
  const search = useSearchParams();
302
304
  const pathname = usePathname();
303
305
  const { status } = useSession();
@@ -395,8 +397,8 @@ const AuthFormBlock = ({
395
397
  React.useEffect(() => {
396
398
  if (!isSignedIn || whenSignedIn !== 'redirect') return;
397
399
 
398
- router.replace(resolveSignedInDestination(signedInHref, search));
399
- }, [isSignedIn, whenSignedIn, signedInHref, router, search]);
400
+ router.replace(localize(resolveSignedInDestination(signedInHref, search)));
401
+ }, [isSignedIn, whenSignedIn, signedInHref, router, search, localize]);
400
402
 
401
403
  /**
402
404
  * The form's contents, wrapped so a Captcha block (or the form's own slot)
@@ -607,7 +609,8 @@ const AuthFormBlock = ({
607
609
  // Our own resolved path rather than next-auth's echo of it: the response
608
610
  // url is whatever we handed in, and this one has been through the
609
611
  // same-origin check. `null` is `stay` — there is nowhere to go.
610
- if (destination) router.push(destination);
612
+ // An authored destination has no locale; a returned one keeps its own.
613
+ if (destination) router.push(localize(destination));
611
614
  } catch {
612
615
  setError(errorLabel);
613
616
  } finally {
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import React from 'react';
4
- import NextLink from 'next/link';
4
+ import { ThemeLink as NextLink } from '../components/theme-link';
5
5
  import { useSession } from 'next-auth/react';
6
6
  import {
7
7
  useGetBasketQuery,
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useState } from 'react';
4
- import NextLink from 'next/link';
4
+ import { ThemeLink as NextLink } from '../components/theme-link';
5
5
  import { useSession } from 'next-auth/react';
6
6
  import {
7
7
  basketApi,
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { useEffect, useState } from 'react';
4
4
  import { useLocalization } from '@akinon/next/hooks';
5
+ import { useLocalizeHref } from '../hooks/use-localize-href';
5
6
  import { useEmailSubscriptionMutation } from '@akinon/next/data/client/misc';
6
7
  import { Modal } from '@akinon/next/components/modal';
7
8
  import {
@@ -26,6 +27,7 @@ const ButtonBlock = ({
26
27
  const action = useBlockAction(block, { currentBreakpoint, isDesigner });
27
28
 
28
29
  const { locale } = useLocalization();
30
+ const localize = useLocalizeHref();
29
31
  const [emailSubscription] = useEmailSubscriptionMutation();
30
32
  const [feedbackModal, setFeedbackModal] = useState({
31
33
  open: false,
@@ -677,7 +679,9 @@ const ButtonBlock = ({
677
679
  return (
678
680
  <>
679
681
  <a
680
- href={url}
682
+ // A plain anchor, so the locale ThemeLink gives other links is added
683
+ // here: an authored `/yatak-odasi/` is the Turkish page.
684
+ href={localize(url)}
681
685
  target={target}
682
686
  rel={target === '_blank' ? 'noopener noreferrer' : undefined}
683
687
  {...partAttrs('root')}