@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
@@ -3,6 +3,7 @@
3
3
  import React from 'react';
4
4
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
5
5
  import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
6
+ import { useLocalizeHref } from '../hooks/use-localize-href';
6
7
  import { useOverlay } from '../hooks/use-overlay';
7
8
  import { BlockRendererProps } from './block-renderer-registry';
8
9
  import {
@@ -136,6 +137,7 @@ const SortSelectBlock = ({
136
137
  const router = useRouter();
137
138
  const pathname = usePathname();
138
139
  const searchParams = useSearchParams();
140
+ const localize = useLocalizeHref();
139
141
 
140
142
  const label = String(
141
143
  getResponsiveValue(block.properties?.label, currentBreakpoint, 'Sort by')
@@ -288,7 +290,10 @@ const SortSelectBlock = ({
288
290
  if (pageQueryParam) params.delete(pageQueryParam);
289
291
  params.set(queryParam, value);
290
292
 
291
- router.push(buildListingHref(pathname || listing?.path || '', params));
293
+ // Commerce's `listing.path` has no locale; `localize` adds the page's.
294
+ router.push(
295
+ localize(buildListingHref(pathname || listing?.path || '', params))
296
+ );
292
297
  },
293
298
  [
294
299
  isDesigner,
@@ -297,7 +302,8 @@ const SortSelectBlock = ({
297
302
  queryParam,
298
303
  router,
299
304
  pathname,
300
- listing?.path
305
+ listing?.path,
306
+ localize
301
307
  ]
302
308
  );
303
309
 
@@ -14,6 +14,7 @@ import {
14
14
  import { useGetRetailStoreStockMutation } from '@akinon/next/data/client/product';
15
15
  import { useLocalization } from '@akinon/next/hooks';
16
16
  import { getResponsiveValue, partAttrs } from '../utils';
17
+ import { useLocalizeHref } from '../hooks/use-localize-href';
17
18
  import { neutralizeStyleTagBreakout } from '../utils/css-safety';
18
19
  import { useOverlay } from '../hooks/use-overlay';
19
20
  import { formatDecimal } from './commerce-format';
@@ -368,6 +369,7 @@ const StoreLocatorBlock = ({
368
369
  : 'center';
369
370
 
370
371
  const { locale } = useLocalization();
372
+ const localize = useLocalizeHref();
371
373
  const { data, isLoading } = useGetStoresQuery(undefined as never);
372
374
  const [selectedCity, setSelectedCity] = useState(ALL_CITIES);
373
375
 
@@ -845,7 +847,7 @@ const StoreLocatorBlock = ({
845
847
  // on — no product on the page, or no city picked. Saying so is the honest
846
848
  // alternative to painting every store with a guess.
847
849
  const showNotice = productMode && Boolean(availabilityNotice) && !stockQuery;
848
- const helpHref = safeHelpHref(availabilityHelpUrl);
850
+ const helpHref = localize(safeHelpHref(availabilityHelpUrl));
849
851
 
850
852
  /**
851
853
  * The same route the unknown marker carries, offered where there is no
@@ -5,12 +5,15 @@ import React, { useMemo } from 'react';
5
5
  import { BlockRendererProps } from './block-renderer-registry';
6
6
  import { getInnerRootInlineStyles, partAttrs } from '../utils';
7
7
  import { useThemeSettingsContext } from '../theme-settings-context';
8
+ import { useLocalizeHref } from '../hooks/use-localize-href';
9
+ import { localizeHtmlLinks } from '../utils/localize-href';
8
10
 
9
11
  const TextBlock = ({
10
12
  block,
11
13
  currentBreakpoint = 'desktop'
12
14
  }: BlockRendererProps) => {
13
15
  const { locale } = useLocalization();
16
+ const localize = useLocalizeHref();
14
17
  const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE || 'en';
15
18
  const themeSettings = useThemeSettingsContext();
16
19
 
@@ -39,6 +42,10 @@ const TextBlock = ({
39
42
  };
40
43
 
41
44
  const content = getLocalizedContent();
45
+ const html = useMemo(
46
+ () => localizeHtmlLinks(content, localize),
47
+ [content, localize]
48
+ );
42
49
 
43
50
  /**
44
51
  * Only the values the generated stylesheet cannot produce. Everything else
@@ -59,7 +66,7 @@ const TextBlock = ({
59
66
  <div
60
67
  {...partAttrs('root')}
61
68
  style={rootStyles}
62
- dangerouslySetInnerHTML={{ __html: content }}
69
+ dangerouslySetInnerHTML={{ __html: html }}
63
70
  />
64
71
  );
65
72
  };
@@ -1,11 +1,12 @@
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 { useRouter } from 'next/navigation';
6
6
  import { useSession } from 'next-auth/react';
7
7
  import { useAddStockAlertMutation } from '@akinon/next/data/client/wishlist';
8
8
  import { getResponsiveValue, partAttrs } from '../utils';
9
+ import { useLocalizeHref } from '../hooks/use-localize-href';
9
10
  import { useOverlay } from '../hooks/use-overlay';
10
11
  import { useDragScroll } from '../hooks/use-drag-scroll';
11
12
  import { BlockRendererProps } from './block-renderer-registry';
@@ -235,6 +236,7 @@ const VariantSelectorBlock = ({
235
236
  }: BlockRendererProps) => {
236
237
  const product = useThemeProduct();
237
238
  const router = useRouter();
239
+ const localize = useLocalizeHref();
238
240
  const { data: session, status: sessionStatus } = useSession();
239
241
  const [addStockAlert, { isLoading: isAlertSubmitting }] =
240
242
  useAddStockAlertMutation();
@@ -1182,7 +1184,9 @@ const VariantSelectorBlock = ({
1182
1184
  const url = event.target.value;
1183
1185
  // Designer clicks must never navigate the preview frame.
1184
1186
  if (isDesigner || !url) return;
1185
- router.push(url);
1187
+ // Same product urls the option links render, and the same
1188
+ // locale they get from ThemeLink.
1189
+ router.push(localize(url));
1186
1190
  }}
1187
1191
  style={{
1188
1192
  width: '100%',
@@ -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 { getResponsiveValue, partAttrs } from '../utils';
6
6
  import { BlockRendererProps } from './block-renderer-registry';
7
7
  import { readToggle } from './product-block-utils';
@@ -0,0 +1,169 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ // zsa-zsa-zsu's setup: Turkish is the hidden default, English lives under /en.
5
+ const TURKISH = {
6
+ locale: 'tr',
7
+ defaultLocaleValue: 'tr',
8
+ localeUrlStrategy: 'hide-default-locale'
9
+ };
10
+ let mockLocalization: Record<string, unknown> = { ...TURKISH };
11
+
12
+ jest.mock('@akinon/next/hooks', () => ({
13
+ __esModule: true,
14
+ useLocalization: () => mockLocalization
15
+ }));
16
+
17
+ // The matcher the core builds for that setup: only `en` is ever a segment.
18
+ jest.mock('@akinon/next/utils', () => ({
19
+ __esModule: true,
20
+ ...jest.requireActual('@akinon/next/utils'),
21
+ urlLocaleMatcherRegex: /^\/(en)(?=\/|$)/
22
+ }));
23
+
24
+ import { ThemeLink } from '../theme-link';
25
+
26
+ const hrefOf = (element: React.ReactElement) =>
27
+ render(element).container.querySelector('a')?.getAttribute('href');
28
+
29
+ beforeEach(() => {
30
+ mockLocalization = { ...TURKISH };
31
+ });
32
+
33
+ describe('ThemeLink', () => {
34
+ it('leaves a storefront path unprefixed on the default locale', () => {
35
+ expect(
36
+ hrefOf(<ThemeLink href="/yatak-odasi/">Yatak odası</ThemeLink>)
37
+ ).toBe('/yatak-odasi/');
38
+ });
39
+
40
+ it('prefixes a storefront path on an English page', () => {
41
+ mockLocalization = { ...TURKISH, locale: 'en' };
42
+
43
+ expect(hrefOf(<ThemeLink href="/yatak-odasi/">Bedroom</ThemeLink>)).toBe(
44
+ '/en/yatak-odasi/'
45
+ );
46
+ expect(hrefOf(<ThemeLink href="/en/yatak-odasi/">Bedroom</ThemeLink>)).toBe(
47
+ '/en/yatak-odasi/'
48
+ );
49
+ });
50
+
51
+ it.each([
52
+ ['#reviews'],
53
+ ['?page=2'],
54
+ ['https://example.com/x'],
55
+ ['//cdn.example.com/x']
56
+ ])('passes %p through on an English page', (href) => {
57
+ mockLocalization = { ...TURKISH, locale: 'en' };
58
+
59
+ expect(hrefOf(<ThemeLink href={href}>x</ThemeLink>)).toBe(href);
60
+ });
61
+
62
+ it('passes a UrlObject href through unchanged', () => {
63
+ mockLocalization = { ...TURKISH, locale: 'en' };
64
+
65
+ // The next/link mock renders an object href as its pathname.
66
+ expect(hrefOf(<ThemeLink href={{ pathname: '/x' }}>x</ThemeLink>)).toBe(
67
+ '/x'
68
+ );
69
+ });
70
+
71
+ it('leaves the core OAuth route unprefixed on an English page', () => {
72
+ mockLocalization = { ...TURKISH, locale: 'en' };
73
+
74
+ expect(hrefOf(<ThemeLink href="/google/login/">Google</ThemeLink>)).toBe(
75
+ '/google/login/'
76
+ );
77
+ });
78
+
79
+ it('leaves a file on this site unprefixed, the way the middleware does', () => {
80
+ mockLocalization = { ...TURKISH, locale: 'en' };
81
+
82
+ expect(hrefOf(<ThemeLink href="/files/kvkk.pdf">KVKK</ThemeLink>)).toBe(
83
+ '/files/kvkk.pdf'
84
+ );
85
+ });
86
+
87
+ /**
88
+ * The storefront's own address reaches the rules as `NEXT_PUBLIC_URL`, which
89
+ * Next inlines into the server render and the client bundle alike — so a
90
+ * merchant's pasted full URL reads as a path on both sides, and the anchor
91
+ * the shopper sees is the same one the server sent.
92
+ */
93
+ describe('with the site address configured', () => {
94
+ const configured = process.env.NEXT_PUBLIC_URL;
95
+
96
+ beforeEach(() => {
97
+ process.env.NEXT_PUBLIC_URL = 'https://www.zsazsazsu.com.tr';
98
+ });
99
+
100
+ afterAll(() => {
101
+ process.env.NEXT_PUBLIC_URL = configured;
102
+ });
103
+
104
+ it('turns a pasted full URL into the localized path', () => {
105
+ mockLocalization = { ...TURKISH, locale: 'en' };
106
+
107
+ expect(
108
+ hrefOf(
109
+ <ThemeLink href="https://www.zsazsazsu.com.tr/yeni-urunler/">
110
+ New in
111
+ </ThemeLink>
112
+ )
113
+ ).toBe('/en/yeni-urunler/');
114
+ });
115
+
116
+ it('keeps that URL as the merchant wrote it on the Turkish page', () => {
117
+ expect(
118
+ hrefOf(
119
+ <ThemeLink href="https://www.zsazsazsu.com.tr/yeni-urunler/">
120
+ Yeni ürünler
121
+ </ThemeLink>
122
+ )
123
+ ).toBe('https://www.zsazsazsu.com.tr/yeni-urunler/');
124
+ });
125
+
126
+ it('leaves a URL somewhere else alone on an English page', () => {
127
+ mockLocalization = { ...TURKISH, locale: 'en' };
128
+
129
+ expect(
130
+ hrefOf(
131
+ <ThemeLink href="https://lookbook.example.com/x">Lookbook</ThemeLink>
132
+ )
133
+ ).toBe('https://lookbook.example.com/x');
134
+ });
135
+ });
136
+
137
+ it('keeps every other prop and forwards a ref to the anchor', () => {
138
+ mockLocalization = { ...TURKISH, locale: 'en' };
139
+ const ref = React.createRef<HTMLAnchorElement>();
140
+
141
+ const { container } = render(
142
+ <ThemeLink
143
+ ref={ref}
144
+ href="/x"
145
+ className="c"
146
+ target="_blank"
147
+ data-part="link"
148
+ >
149
+ label
150
+ </ThemeLink>
151
+ );
152
+ const anchor = container.querySelector('a') as HTMLAnchorElement;
153
+
154
+ expect(anchor).toHaveAttribute('class', 'c');
155
+ expect(anchor).toHaveAttribute('target', '_blank');
156
+ expect(anchor).toHaveAttribute('data-part', 'link');
157
+ expect(anchor).toHaveTextContent('label');
158
+ expect(ref.current).toBe(anchor);
159
+ });
160
+
161
+ it('is a forwardRef component, so a ref survives on React 18', () => {
162
+ // This jest runs React 19, where `ref` is a plain prop and would reach the
163
+ // anchor through `...rest` even without forwardRef; the brands run React 18,
164
+ // where it would be dropped. So the wrapper type itself is what is checked.
165
+ expect((ThemeLink as unknown as { $$typeof: symbol }).$$typeof).toBe(
166
+ Symbol.for('react.forward_ref')
167
+ );
168
+ });
169
+ });
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ import React, { forwardRef } from 'react';
4
+ import NextLink from 'next/link';
5
+ import { useLocalizeHref } from '../hooks/use-localize-href';
6
+
7
+ type ThemeLinkProps = React.ComponentPropsWithoutRef<typeof NextLink>;
8
+
9
+ /**
10
+ * `next/link` for every link a block renders, with the active locale on it.
11
+ *
12
+ * Content hrefs are stored without a locale, so on an English page a plain
13
+ * `next/link` pointed at the Turkish site — and prefetched it, which switched
14
+ * the shopper's `pz-locale` cookie to Turkish behind their back. Only a string
15
+ * root-relative href is rewritten (see `localizeHref`); hash, query, external
16
+ * and `UrlObject` hrefs reach `next/link` exactly as given.
17
+ *
18
+ * Rendered through `next/link` rather than `@akinon/next`'s `Link` so a ref
19
+ * still reaches the anchor, and so links and imperative navigations share one
20
+ * rule set.
21
+ */
22
+ export const ThemeLink = forwardRef<HTMLAnchorElement, ThemeLinkProps>(
23
+ function ThemeLink({ href, ...rest }, ref) {
24
+ const localize = useLocalizeHref();
25
+
26
+ return (
27
+ <NextLink
28
+ ref={ref}
29
+ href={typeof href === 'string' ? localize(href) : href}
30
+ {...rest}
31
+ />
32
+ );
33
+ }
34
+ );
35
+
36
+ export default ThemeLink;
@@ -290,6 +290,12 @@ section's widget still answers for the previous collection, and the snapshot is
290
290
  the right thing to draw. A section that fetches its own widget on the canvas
291
291
  should do so only when `dataSourceSaved` is `true`.
292
292
 
293
+ **The addresses in commerce data have no locale.** A product's
294
+ `absolute_url` and a menu node's `url` are bare paths (`/yatak-odasi/`) on
295
+ the English storefront too. Run each one through `useLocalizeHref()` where
296
+ you read it, or render it with `ThemeLink`. See "Links and the page's
297
+ language" in [README.md](./README.md).
298
+
293
299
  ## Parts must live on YOUR markup
294
300
 
295
301
  Part rules are generated with an ownership guard that excludes anything inside a
@@ -144,6 +144,55 @@ guessed, and the message says which: a custom component instead of a host
144
144
  element, a bare expression with no element, and an ambiguous multi-child
145
145
  element. `type=` is the explicit escape.
146
146
 
147
+ The authored text is written in the store's default language. Its other
148
+ languages go in `translations`:
149
+
150
+ ```tsx
151
+ <ThemeEditable name="label" translations={{ en: 'MENU' }}>
152
+ <span>MENÜ</span>
153
+ </ThemeEditable>
154
+ ```
155
+
156
+ The editor is told `MENÜ` only. An English page shows `MENU` for as long as
157
+ the field still says `MENÜ`, including the copy the editor seeds when the
158
+ block is placed. Once the merchant types their own text, it shows as typed.
159
+ This is the same rule as a per-language `useThemeSetting` default (below); a
160
+ setting read for the same key, such as an accessible name, can share one
161
+ constant with it.
162
+
163
+ ## Links and the page's language
164
+
165
+ Storefront paths are stored and authored without a locale: `/yatak-odasi/`,
166
+ never `/en/yatak-odasi/`. On an English page that bare path is the Turkish
167
+ site, so a link that goes out as written takes the shopper out of English. A
168
+ visible `next/link` also prefetches it, which makes the middleware set
169
+ `pz-locale=tr`, and every request after that comes back Turkish. You do not
170
+ write the locale yourself; there are three places it goes on for you:
171
+
172
+ | What you render | What to use |
173
+ |---|---|
174
+ | The `<a>` that `ThemeEditable` wraps | Nothing: the locale is added to its href, whether the merchant set it or you did |
175
+ | Any other link | `ThemeLink` in place of `next/link`, with the same props |
176
+ | A bare string: a plain `<a>`, `router.push`, a url read off commerce data | `const localize = useLocalizeHref();` then `localize(url)` |
177
+
178
+ All three come from `@akinon/pz-theme/src/custom`. An `<a>` nested deeper
179
+ inside a `ThemeEditable` is not touched — that one is a link of yours, so it
180
+ takes the row it belongs to above.
181
+
182
+ A full URL to this same storefront counts as a path, `www.` or not: merchants
183
+ paste the address bar, and `https://www.zsazsazsu.com.tr/yeni-urunler/` on an
184
+ English page becomes `/en/yeni-urunler/` — which also lets the router take it
185
+ without a page load. Addresses with no localized copy stay as they are: `/api`
186
+ and `/_next`, and anything ending in a file (`/files/kvkk.pdf`,
187
+ `/manifest.json`). So do external, `#hash`, `?query` and empty hrefs, and the
188
+ OAuth `/{provider}/login/` routes.
189
+
190
+ No locale is added when the URL strategy hides the page's own: the default
191
+ locale under `hide-default-locale`, every locale under `subdomain` and
192
+ `hide-all-locales`. To compare an address with the page's own, localize the
193
+ stored side rather than stripping the locale off the current one:
194
+ `localize(node.url) === pathname`.
195
+
147
196
  ## `useThemeSetting`
148
197
 
149
198
  For values with no on-screen representation — how many products to fetch, which
@@ -153,6 +202,28 @@ it every variation becomes a separate block in the picker.
153
202
  React's own rule that hooks are called unconditionally is doing useful work
154
203
  here: the language enforces that the declaration set is static.
155
204
 
205
+ A `text` default can be written per language, and the hook still returns one
206
+ string, the entry for the page's locale:
207
+
208
+ ```tsx
209
+ const label = useThemeSetting('label', {
210
+ type: 'text',
211
+ label: 'Label when closed',
212
+ default: { tr: 'MENÜ', en: 'MENU' }
213
+ });
214
+ ```
215
+
216
+ The editor is only told the default language's entry, so a static `settings`
217
+ declaration keeps that one plain string (`defaultValue: 'MENÜ'`). The
218
+ translation applies for as long as the stored value is still that default,
219
+ including the copy the editor seeds when the block is placed. Once the
220
+ merchant types their own text, it shows as typed in every language. A text
221
+ the merchant can translate themselves needs the `localetext` control, which a
222
+ custom setting cannot declare yet: this package refuses the type (see
223
+ `collect.ts`) because the editor stores such a value as a locale map with no
224
+ breakpoint key, and the editor refuses it in a custom catalog for the same
225
+ reason.
226
+
156
227
  ## `ThemeSlot`
157
228
 
158
229
  A merchant-fillable region. A block may open several, and each is filled
@@ -7,6 +7,8 @@ import * as chromeMode from '../../chrome/chrome-mode';
7
7
  import * as chromeModeHook from '../../chrome/use-chrome-mode';
8
8
  import * as blockContext from '../block-context';
9
9
  import * as overlay from '../../hooks/use-overlay';
10
+ import * as themeLink from '../../components/theme-link';
11
+ import * as localizeHrefHook from '../../hooks/use-localize-href';
10
12
  import { resetCustomCollection } from '../collect';
11
13
  import { CustomSectionHost } from '../section-host';
12
14
  import type { Section } from '../../theme-section';
@@ -20,7 +22,9 @@ import {
20
22
  defineCustomSection,
21
23
  setChromeMode,
22
24
  useChromeMode,
25
+ ThemeLink,
23
26
  useCustomBlockContext,
27
+ useLocalizeHref,
24
28
  useOverlay
25
29
  } from '../index';
26
30
  import type {
@@ -248,3 +252,12 @@ describe('useOverlay from the custom entry', () => {
248
252
  expect(screen.getByRole('dialog').hasAttribute('aria-modal')).toBe(false);
249
253
  });
250
254
  });
255
+
256
+ describe('the custom entry exports ZERO-5100 added', () => {
257
+ // A custom block's links need the page's locale, and the barrel is the one
258
+ // path a brand developer imports from, so the locale helpers sit there too.
259
+ it('re-exports the locale-aware link and hook rather than second copies', () => {
260
+ expect(ThemeLink).toBe(themeLink.ThemeLink);
261
+ expect(useLocalizeHref).toBe(localizeHrefHook.useLocalizeHref);
262
+ });
263
+ });
@@ -255,6 +255,185 @@ describe('ThemeEditable', () => {
255
255
  });
256
256
  });
257
257
 
258
+ /**
259
+ * ZERO-5100: the page's locale goes on the link here, so a developer writing
260
+ * a custom block never has to. Turkish is the hidden default and English
261
+ * lives under /en, which is zsa-zsa-zsu's setup.
262
+ */
263
+ describe('links carry the page’s locale', () => {
264
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
265
+ const hooks = require('@akinon/next/hooks');
266
+ let localizationSpy: jest.SpyInstance;
267
+
268
+ const onPage = (locale: string) => {
269
+ const original = hooks.useLocalization;
270
+ localizationSpy = jest
271
+ .spyOn(hooks, 'useLocalization')
272
+ .mockImplementation(() => ({
273
+ ...original(),
274
+ locale,
275
+ defaultLocaleValue: 'tr',
276
+ localeUrlStrategy: 'hide-default-locale'
277
+ }));
278
+ };
279
+
280
+ afterEach(() => localizationSpy?.mockRestore());
281
+
282
+ const hrefOf = (container: HTMLElement) =>
283
+ (container.querySelector('a') as HTMLAnchorElement).getAttribute('href');
284
+
285
+ it('prefixes the merchant’s destination on an English page', () => {
286
+ onPage('en');
287
+ const { container } = renderInBlock(
288
+ <ThemeEditable name="cta">
289
+ <a href="/sale">Shop now</a>
290
+ </ThemeEditable>,
291
+ { properties: { ctaHref: { desktop: '/new-season/' } } }
292
+ );
293
+
294
+ expect(hrefOf(container)).toBe('/en/new-season/');
295
+ });
296
+
297
+ it('prefixes the authored destination when the merchant set none', () => {
298
+ onPage('en');
299
+ const { container } = renderInBlock(
300
+ <ThemeEditable name="cta">
301
+ <a href="/sale">Shop now</a>
302
+ </ThemeEditable>
303
+ );
304
+
305
+ expect(hrefOf(container)).toBe('/en/sale');
306
+ });
307
+
308
+ it('prefixes an <a> kept as a text field too', () => {
309
+ onPage('en');
310
+ const { container } = renderInBlock(
311
+ <ThemeEditable name="title" type="text">
312
+ <a href="/sale">Shop now</a>
313
+ </ThemeEditable>
314
+ );
315
+
316
+ expect(hrefOf(container)).toBe('/en/sale');
317
+ });
318
+
319
+ it('leaves external, hash and empty destinations alone', () => {
320
+ onPage('en');
321
+
322
+ for (const destination of ['https://example.com/', '#top', '']) {
323
+ const { container, unmount } = renderInBlock(
324
+ <ThemeEditable name="cta">
325
+ <a href="/sale">Shop now</a>
326
+ </ThemeEditable>,
327
+ { properties: { ctaHref: { desktop: destination } } }
328
+ );
329
+
330
+ expect(hrefOf(container)).toBe(destination);
331
+ unmount();
332
+ }
333
+ });
334
+
335
+ it('adds nothing on the default-locale page', () => {
336
+ onPage('tr');
337
+ const { container } = renderInBlock(
338
+ <ThemeEditable name="cta">
339
+ <a href="/sale">Shop now</a>
340
+ </ThemeEditable>,
341
+ { properties: { ctaHref: { desktop: '/new-season/' } } }
342
+ );
343
+
344
+ expect(hrefOf(container)).toBe('/new-season/');
345
+ });
346
+
347
+ it('leaves the stored value alone', () => {
348
+ onPage('en');
349
+ const properties = { ctaHref: { desktop: '/new-season/' } };
350
+ renderInBlock(
351
+ <ThemeEditable name="cta">
352
+ <a href="/sale">Shop now</a>
353
+ </ThemeEditable>,
354
+ { properties }
355
+ );
356
+
357
+ expect(properties.ctaHref.desktop).toBe('/new-season/');
358
+ });
359
+ });
360
+
361
+ /**
362
+ * The author's own text in the store's other languages. The child stays the
363
+ * default language's copy, which is what the editor is told and seeds.
364
+ */
365
+ describe('translations of the authored text', () => {
366
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
367
+ const hooks = require('@akinon/next/hooks');
368
+ let localizationSpy: jest.SpyInstance;
369
+
370
+ const onPage = (locale: string) => {
371
+ const original = hooks.useLocalization;
372
+ localizationSpy = jest
373
+ .spyOn(hooks, 'useLocalization')
374
+ .mockImplementation(() => ({
375
+ ...original(),
376
+ locale,
377
+ defaultLocaleValue: 'tr',
378
+ locales: [{ value: 'tr' }, { value: 'en' }]
379
+ }));
380
+ };
381
+
382
+ afterEach(() => localizationSpy?.mockRestore());
383
+
384
+ const trigger = (properties?: Record<string, unknown>) =>
385
+ renderInBlock(
386
+ <ThemeEditable name="label" translations={{ en: 'MENU' }}>
387
+ <span>MENÜ</span>
388
+ </ThemeEditable>,
389
+ properties ? { properties } : {}
390
+ ).container.querySelector('span')?.textContent;
391
+
392
+ it('shows the page’s language when nothing is stored', () => {
393
+ onPage('en');
394
+ expect(trigger()).toBe('MENU');
395
+ });
396
+
397
+ it('translates the copy the editor seeded when the block was placed', () => {
398
+ onPage('en');
399
+ expect(trigger({ label: { desktop: 'MENÜ' } })).toBe('MENU');
400
+ });
401
+
402
+ it('shows the merchant’s own text as typed', () => {
403
+ onPage('en');
404
+ expect(trigger({ label: { desktop: 'Kategoriler' } })).toBe(
405
+ 'Kategoriler'
406
+ );
407
+ });
408
+
409
+ it('keeps the authored text on the default-language page', () => {
410
+ onPage('tr');
411
+ expect(trigger()).toBe('MENÜ');
412
+ });
413
+
414
+ it('keeps the authored text for a language with no translation', () => {
415
+ onPage('de');
416
+ expect(trigger()).toBe('MENÜ');
417
+ });
418
+
419
+ it('reports the default language’s text to the editor', () => {
420
+ onPage('en');
421
+ const collect = { setting: jest.fn(), slot: jest.fn() };
422
+
423
+ renderInBlock(
424
+ <ThemeEditable name="label" translations={{ en: 'MENU' }}>
425
+ <span>MENÜ</span>
426
+ </ThemeEditable>,
427
+ { collect }
428
+ );
429
+
430
+ expect(collect.setting.mock.calls[0][0]).toMatchObject({
431
+ key: 'label',
432
+ defaultValue: { desktop: 'MENÜ' }
433
+ });
434
+ });
435
+ });
436
+
258
437
  describe('the authored JSX is the default', () => {
259
438
  it('renders the authored content when the block has no properties', () => {
260
439
  const { container } = renderInBlock(