@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
@@ -231,6 +231,107 @@ describe('useThemeSetting — used outside a custom block', () => {
231
231
  });
232
232
  });
233
233
 
234
+ /**
235
+ * A `text` default written per language: the author's own copy follows the
236
+ * page's language, while the editor keeps seeing one plain string. Turkish is
237
+ * the hidden default and English lives under /en, which is zsa-zsa-zsu's setup.
238
+ */
239
+ describe('useThemeSetting — a text default written per language', () => {
240
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
241
+ const hooks = require('@akinon/next/hooks');
242
+ let localizationSpy: jest.SpyInstance;
243
+
244
+ const onPage = (locale: string) => {
245
+ const original = hooks.useLocalization;
246
+ localizationSpy = jest
247
+ .spyOn(hooks, 'useLocalization')
248
+ .mockImplementation(() => ({
249
+ ...original(),
250
+ locale,
251
+ defaultLocaleValue: 'tr',
252
+ locales: [{ value: 'tr' }, { value: 'en' }]
253
+ }));
254
+ };
255
+
256
+ afterEach(() => localizationSpy?.mockRestore());
257
+
258
+ const LABEL = { tr: 'MENÜ', en: 'MENU' };
259
+
260
+ const read = (
261
+ value: CustomBlockContextValue | null,
262
+ fallback: unknown = LABEL
263
+ ) =>
264
+ renderHook(
265
+ () =>
266
+ useThemeSetting('menuLabel', {
267
+ type: 'text',
268
+ label: 'Menu label',
269
+ default: fallback as Record<string, string>
270
+ }),
271
+ { wrapper: wrapperFor(value) }
272
+ ).result.current;
273
+
274
+ it('reads the entry for the page’s language when nothing is stored', () => {
275
+ onPage('en');
276
+ expect(read(contextValue())).toBe('MENU');
277
+
278
+ localizationSpy.mockRestore();
279
+ onPage('tr');
280
+ expect(read(contextValue())).toBe('MENÜ');
281
+ });
282
+
283
+ it('translates the default the editor seeded when the block was placed', () => {
284
+ onPage('en');
285
+ const seeded = contextValue({
286
+ properties: { menuLabel: { desktop: 'MENÜ' } }
287
+ });
288
+
289
+ expect(read(seeded)).toBe('MENU');
290
+ });
291
+
292
+ it('shows the merchant’s own text as typed, in every language', () => {
293
+ onPage('en');
294
+ const typed = contextValue({
295
+ properties: { menuLabel: { desktop: 'Kategoriler' } }
296
+ });
297
+
298
+ expect(read(typed)).toBe('Kategoriler');
299
+ });
300
+
301
+ it('tells the editor the default language’s text, as one plain string', () => {
302
+ onPage('en');
303
+ const sink = jest.fn();
304
+ read(contextValue({ sink }));
305
+
306
+ expect(sink.mock.calls[0][0]).toMatchObject({
307
+ key: 'menuLabel',
308
+ type: 'text',
309
+ defaultValue: { desktop: 'MENÜ' }
310
+ });
311
+ });
312
+
313
+ it('falls back to the default language for a language the author skipped', () => {
314
+ onPage('en');
315
+ expect(read(contextValue(), { tr: 'Tümü' })).toBe('Tümü');
316
+ });
317
+
318
+ it('reads the page’s entry outside a custom block too', () => {
319
+ onPage('en');
320
+ const warn = jest
321
+ .spyOn(console, 'warn')
322
+ .mockImplementation(() => undefined);
323
+
324
+ expect(read(null)).toBe('MENU');
325
+
326
+ warn.mockRestore();
327
+ });
328
+
329
+ it('leaves a one-string default exactly as it was', () => {
330
+ onPage('en');
331
+ expect(read(contextValue(), 'MENÜ')).toBe('MENÜ');
332
+ });
333
+ });
334
+
234
335
  describe('useThemeSetting — invalid declarations throw in development', () => {
235
336
  const renderWith = (key: string, options: Record<string, unknown>) =>
236
337
  renderHook(
@@ -137,6 +137,22 @@ export type {
137
137
  ThemeSettingOptions
138
138
  } from './use-theme-setting';
139
139
 
140
+ /**
141
+ * LINKS AND THE PAGE'S LOCALE. Storefront paths are stored and authored
142
+ * without one ('/yatak-odasi/'), which on an English page is the Turkish site.
143
+ * The `<a>` that `ThemeEditable` wraps gets the locale on its own. For any
144
+ * other link use `ThemeLink` in place of `next/link`, and for a bare string — a
145
+ * plain `<a>`, `router.push`, an address read off commerce data — run it
146
+ * through the function `useLocalizeHref()` returns. Nothing else in a block has
147
+ * to know which language the page is in.
148
+ *
149
+ * This entry, not the package root: the root re-exports `ThemePlaceholder`,
150
+ * which is server-only, so a client component importing from there fails the
151
+ * build.
152
+ */
153
+ export { ThemeLink } from '../components/theme-link';
154
+ export { useLocalizeHref } from '../hooks/use-localize-href';
155
+
140
156
  /**
141
157
  * The context every custom unit renders inside, for the one question the
142
158
  * components above cannot answer for a developer: is this the editor canvas?
@@ -2,8 +2,10 @@
2
2
 
3
3
  import React from 'react';
4
4
 
5
+ import { useLocalizeHref } from '../hooks/use-localize-href';
5
6
  import { getResponsiveValue } from '../utils';
6
7
  import { devWarn } from '../utils/dev-warn';
8
+ import { useValueLocale } from '../utils/localized-value';
7
9
  import { partAttrs } from '../utils/part-styles';
8
10
  import { useCustomBlockContext } from './block-context';
9
11
  import type { CustomBlockContextValue } from './block-context';
@@ -113,6 +115,15 @@ export interface ThemeEditableProps {
113
115
  name: string;
114
116
  /** Overrides tag inference. The escape hatch for a container tag. */
115
117
  type?: EditableKind;
118
+ /**
119
+ * The authored text in the store's other languages, keyed by locale:
120
+ * `<ThemeEditable name="label" translations={{ en: 'MENU' }}><span>MENÜ</span>`.
121
+ * The child stays the default language's text, which is all the editor is
122
+ * told. Shown for as long as the field still says that text, including the
123
+ * copy the editor seeds when the block is placed. Text the merchant typed is
124
+ * theirs and shows as typed. Text and link labels only.
125
+ */
126
+ translations?: Readonly<Record<string, string>>;
116
127
  children?: React.ReactNode;
117
128
  }
118
129
 
@@ -148,13 +159,20 @@ export interface ThemeEditableProps {
148
159
  export function ThemeEditable({
149
160
  name,
150
161
  type,
162
+ translations,
151
163
  children
152
164
  }: ThemeEditableProps): React.ReactElement {
153
165
  const context = useCustomBlockContext();
154
-
155
- // Untouched rather than empty, so a `<ThemeEditable>` copy-pasted into a
156
- // plain component keeps rendering what the developer wrote while the warning
157
- // tells them why nothing is editable.
166
+ const localizeHref = useLocalizeHref();
167
+ const valueLocale = useValueLocale();
168
+
169
+ // Untouched rather than empty, so a `<ThemeEditable>` copy-pasted outside a
170
+ // custom block keeps rendering what the developer wrote while the warning
171
+ // tells them why nothing is editable. What it does need is a Next app router
172
+ // page and the `LocalizationProvider` around it: the two hooks above reach
173
+ // `@akinon/next`'s `useLocalization`, which calls `next/navigation`'s
174
+ // `useRouter`. A test gets there with a mocked `@akinon/next/hooks`, the way
175
+ // this package's jest config does.
158
176
  if (!context) {
159
177
  devWarn(
160
178
  'ThemeEditable',
@@ -314,6 +332,25 @@ export function ThemeEditable({
314
332
  )
315
333
  };
316
334
 
335
+ // The author's own text, still untouched, in the page's language. Compared
336
+ // with the authored child rather than "nothing stored" because the editor
337
+ // stores that child as the value when the block is placed: a trigger placed
338
+ // on a Turkish store carries `label: { desktop: 'MENÜ' }` from day one.
339
+ const translated =
340
+ valueProp === 'children' &&
341
+ valueLocale.locale &&
342
+ valueLocale.locale !== valueLocale.defaultLocale
343
+ ? translations?.[valueLocale.locale]
344
+ : undefined;
345
+
346
+ if (
347
+ typeof translated === 'string' &&
348
+ translated !== '' &&
349
+ nextProps[valueProp] === authoredValue
350
+ ) {
351
+ nextProps[valueProp] = translated;
352
+ }
353
+
317
354
  if (hrefKey) {
318
355
  // A destination is a plain string in every control that writes one, so it
319
356
  // never carries the picker's `{ url }` wrapper.
@@ -325,6 +362,22 @@ export function ThemeEditable({
325
362
  );
326
363
  }
327
364
 
365
+ // The page's locale goes on the destination here, once, for every custom
366
+ // block and section. The stored address and the authored JSX both say
367
+ // '/yatak-odasi/', which on an English page is the Turkish site, and a
368
+ // developer who had to remember `/en` on each link would forget one. Only a
369
+ // root-relative href changes (see `localizeHref`); the stored value is left
370
+ // alone, because the editor reads properties, never the rendered anchor.
371
+ const destination = hrefKey
372
+ ? nextProps.href
373
+ : tag === 'a'
374
+ ? childProps.href
375
+ : undefined;
376
+
377
+ if (typeof destination === 'string') {
378
+ nextProps.href = localizeHref(destination);
379
+ }
380
+
328
381
  if (stampsFieldPart) {
329
382
  // `use-designer-features.tsx` resolves the nearest `[data-part]` under the
330
383
  // click and ships its value as `partName` on SELECT_BLOCK, so this one
@@ -2,6 +2,12 @@
2
2
 
3
3
  import { getResponsiveValue } from '../utils';
4
4
  import { devWarn } from '../utils/dev-warn';
5
+ import {
6
+ isLocalizedValue,
7
+ resolveLocalizedValue,
8
+ useValueLocale
9
+ } from '../utils/localized-value';
10
+ import type { ValueLocale } from '../utils/localized-value';
5
11
  import { useCustomBlockContext } from './block-context';
6
12
  import {
7
13
  normalizeSettingChoices,
@@ -52,13 +58,22 @@ interface SettingValueByType {
52
58
  */
53
59
  type NoInferred<T> = [T][T extends unknown ? 0 : never];
54
60
 
61
+ /**
62
+ * A `text` default may be written per language — `{ tr: 'MENÜ', en: 'MENU' }`
63
+ * — and the hook still returns one string: the entry for the page's locale.
64
+ * See `splitDefault`.
65
+ */
66
+ type DefaultFor<TType, TValue> = TType extends 'text'
67
+ ? NoInferred<TValue> | Readonly<Record<string, string>>
68
+ : NoInferred<TValue>;
69
+
55
70
  export interface ThemeSettingOptions<
56
71
  TType extends PropertyType = PropertyType,
57
72
  TValue = unknown
58
73
  > {
59
74
  type: TType;
60
75
  label: string;
61
- default?: NoInferred<TValue>;
76
+ default?: DefaultFor<TType, TValue>;
62
77
  options?: ReadonlyArray<ThemeSettingChoice>;
63
78
  min?: number;
64
79
  max?: number;
@@ -156,6 +171,41 @@ const resolveAuthoredDefault = (
156
171
  return resolved === NOT_AUTHORED ? value : resolved;
157
172
  };
158
173
 
174
+ /**
175
+ * A `text` default written per language, split into its two readings.
176
+ *
177
+ * `base` is the default language's entry. It is what the editor is told the
178
+ * default is, so the panel, the seed a newly placed block is stored with and
179
+ * the static `settings` declaration all keep the plain string they always had,
180
+ * and a catalog declaring one is accepted by an editor that has never heard of
181
+ * languages. `page` is the entry for the locale being shown.
182
+ *
183
+ * Any other default, and a `text` default written as one string, reads the
184
+ * same both ways.
185
+ */
186
+ const splitDefault = (
187
+ declaration: ThemeSettingOptions<PropertyType, unknown>,
188
+ valueLocale: ValueLocale
189
+ ): { base: unknown; page: unknown; perLocale: boolean } => {
190
+ const value = declaration.default;
191
+
192
+ if (
193
+ declaration.type !== 'text' ||
194
+ !isLocalizedValue(value, valueLocale.locales)
195
+ ) {
196
+ return { base: value, page: value, perLocale: false };
197
+ }
198
+
199
+ return {
200
+ base: resolveLocalizedValue(value, {
201
+ ...valueLocale,
202
+ locale: valueLocale.defaultLocale
203
+ }),
204
+ page: resolveLocalizedValue(value, valueLocale),
205
+ perLocale: true
206
+ };
207
+ };
208
+
159
209
  /**
160
210
  * Declares one merchant-editable value that has no on-screen representation —
161
211
  * how many products a grid shows, which category it pulls from, whether a
@@ -181,13 +231,21 @@ export function useThemeSetting<
181
231
  TValue = SettingValueByType[TType]
182
232
  >(key: string, options: ThemeSettingOptions<TType, TValue>): TValue {
183
233
  const context = useCustomBlockContext();
234
+ const valueLocale = useValueLocale();
184
235
  const declaration = options as ThemeSettingOptions<PropertyType, unknown>;
185
236
 
186
237
  assertDeclarationIsValid(key, declaration);
187
238
 
239
+ const defaults = splitDefault(declaration, valueLocale);
240
+
188
241
  if (!context) {
189
- // Never throws: a developer previewing the component in a plain React tree,
190
- // a storybook or a unit test should see the declared defaults, not a crash.
242
+ // A missing block context is never an error on its own: a developer
243
+ // previewing the component in a storybook or a unit test should see the
244
+ // declared defaults, not a crash. The tree around it is not free of
245
+ // requirements, though — `useValueLocale` above reads `@akinon/next`'s
246
+ // `useLocalization`, which calls `next/navigation`'s `useRouter` — so the
247
+ // render still needs a Next app router and the `LocalizationProvider`, or
248
+ // a mocked `@akinon/next/hooks` the way this package's jest config does.
191
249
  // The key rides in the message so distinct misplacements dedupe
192
250
  // independently inside `devWarn`.
193
251
  devWarn(
@@ -196,13 +254,15 @@ export function useThemeSetting<
196
254
  `unreachable and the declared default is used instead. Render this ` +
197
255
  `component through a block registered with registerCustomBlocks().`
198
256
  );
199
- return declaration.default as TValue;
257
+ return defaults.page as TValue;
200
258
  }
201
259
 
202
260
  // Reported on every render rather than once from an effect: the collection
203
261
  // pass renders the component and reads the sink synchronously, and on the
204
262
  // server effects never run at all.
205
- context.collect?.setting(buildDescriptor(key, declaration));
263
+ context.collect?.setting(
264
+ buildDescriptor(key, { ...declaration, default: defaults.base })
265
+ );
206
266
 
207
267
  const breakpoint = context.currentBreakpoint;
208
268
 
@@ -219,9 +279,16 @@ export function useThemeSetting<
219
279
  // undefined.
220
280
  const resolved = getResponsiveValue(stored, breakpoint, NOT_AUTHORED);
221
281
 
222
- return (
223
- resolved === NOT_AUTHORED
224
- ? resolveAuthoredDefault(declaration.default, breakpoint)
225
- : resolved
226
- ) as TValue;
282
+ if (resolved === NOT_AUTHORED) {
283
+ return resolveAuthoredDefault(defaults.page, breakpoint) as TValue;
284
+ }
285
+
286
+ // The default-language text as the editor seeded it when the block was
287
+ // placed, never changed since: still the author's copy, so it follows the
288
+ // page's language. Anything the merchant typed is theirs and shows as typed.
289
+ if (defaults.perLocale && resolved === defaults.base) {
290
+ return defaults.page as TValue;
291
+ }
292
+
293
+ return resolved as TValue;
227
294
  }
@@ -0,0 +1,34 @@
1
+ 'use client';
2
+
3
+ import { useCallback } from 'react';
4
+ import { useLocalization } from '@akinon/next/hooks';
5
+ import { urlLocaleMatcherRegex } from '@akinon/next/utils';
6
+ import { localizeHref } from '../utils/localize-href';
7
+
8
+ /**
9
+ * `localizeHref` bound to the active locale, for anything that navigates
10
+ * without a `ThemeLink`: `router.push`, a plain `<a>`, a fetched page URL.
11
+ *
12
+ * Everything that localizes goes through this hook rather than calling
13
+ * `localizeHref` itself, because this is the only place that knows the
14
+ * storefront's own address — and without it a merchant's full URL to this same
15
+ * site would not be recognized as a path. `NEXT_PUBLIC_URL` is a public env
16
+ * var, so Next inlines the same literal into the server render and the client
17
+ * bundle, and the two cannot disagree about which links are internal.
18
+ */
19
+ export const useLocalizeHref = (): ((href: string) => string) => {
20
+ const { locale, defaultLocaleValue, localeUrlStrategy } = useLocalization();
21
+ const siteUrl = process.env.NEXT_PUBLIC_URL;
22
+
23
+ return useCallback(
24
+ (href: string) =>
25
+ localizeHref(href, {
26
+ locale,
27
+ defaultLocaleValue,
28
+ localeUrlStrategy,
29
+ localeMatcher: urlLocaleMatcherRegex,
30
+ siteUrl
31
+ }),
32
+ [locale, defaultLocaleValue, localeUrlStrategy, siteUrl]
33
+ );
34
+ };
package/src/index.ts CHANGED
@@ -27,6 +27,18 @@ export { isBrowseMode, useBrowseMode } from './browse-mode';
27
27
 
28
28
  export { WithDesignerFeatures } from './components/with-designer-features';
29
29
 
30
+ // The locale rules as plain functions, for code that already has the store's
31
+ // localization settings in hand. `ThemeLink` and `useLocalizeHref` are NOT
32
+ // here: this entry re-exports `ThemePlaceholder`, which imports 'server-only',
33
+ // so importing anything from it inside a client component fails the build. A
34
+ // brand's custom section takes both from '@akinon/pz-theme/src/custom'.
35
+ export {
36
+ isRootRelativeHref,
37
+ localizeHref,
38
+ localizeHtmlLinks
39
+ } from './utils/localize-href';
40
+ export type { HrefLocale } from './utils/localize-href';
41
+
30
42
  export type { Section } from './theme-section';
31
43
  export type { Block } from './theme-block';
32
44
 
@@ -28,6 +28,7 @@ import {
28
28
  resolveThemeCssVariables
29
29
  } from '../utils';
30
30
  import { isNarrowBreakpoint } from '../utils/breakpoints';
31
+ import { useLocalizeHref } from '../hooks/use-localize-href';
31
32
 
32
33
  import { postToEditor } from '../editor-bridge';
33
34
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
@@ -534,6 +535,7 @@ export default function FindInStoreSection({
534
535
  selectedBlockId = null
535
536
  }: FindInStoreSectionProps) {
536
537
  const { t } = useLocalization();
538
+ const localize = useLocalizeHref();
537
539
  const themeSettings = useThemeSettingsContext();
538
540
 
539
541
  const [productUrlInput, setProductUrlInput] = useState('');
@@ -1462,7 +1464,7 @@ export default function FindInStoreSection({
1462
1464
  )
1463
1465
  ) : productUrl ? (
1464
1466
  <a
1465
- href={productUrl}
1467
+ href={localize(productUrl)}
1466
1468
  className="text-[13px] font-semibold text-slate-800 underline underline-offset-4"
1467
1469
  >
1468
1470
  View product
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { useState, useEffect } from 'react';
4
4
  import { useLocalization } from '@akinon/next/hooks';
5
+ import { useLocalizeHref } from '../hooks/use-localize-href';
5
6
  import ThemeBlock, { Block } from '../theme-block';
6
7
  import { useThemeSettingsContext } from '../theme-settings-context';
7
8
  import {
@@ -32,6 +33,9 @@ const HoverShowcaseSection: React.FC<HoverShowcaseSectionProps> = ({
32
33
  selectedBlockId = null
33
34
  }) => {
34
35
  const { locale } = useLocalization();
36
+ // The section draws its items itself (never through the item block), so
37
+ // their plain anchors need the active locale added here.
38
+ const localize = useLocalizeHref();
35
39
  const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE || 'en';
36
40
  const themeSettings = useThemeSettingsContext();
37
41
 
@@ -229,7 +233,7 @@ const HoverShowcaseSection: React.FC<HoverShowcaseSectionProps> = ({
229
233
  onClick={() => setActiveIndex(index)}
230
234
  >
231
235
  {link ? (
232
- <a href={link} className="decoration-none">
236
+ <a href={localize(link)} className="decoration-none">
233
237
  <h3
234
238
  style={{
235
239
  fontSize: String(titleFontSize),
@@ -378,7 +382,7 @@ const HoverShowcaseSection: React.FC<HoverShowcaseSectionProps> = ({
378
382
  onMouseEnter={() => setActiveIndex(index)}
379
383
  >
380
384
  {link ? (
381
- <a href={link} className="decoration-none">
385
+ <a href={localize(link)} className="decoration-none">
382
386
  <h3
383
387
  style={{
384
388
  fontSize: String(titleFontSize),
@@ -21,6 +21,7 @@ import {
21
21
  resolveThemeCssVariables
22
22
  } from '../utils';
23
23
  import { isNarrowBreakpoint } from '../utils/breakpoints';
24
+ import { useLocalizeHref } from '../hooks/use-localize-href';
24
25
 
25
26
  import { postToEditor } from '../editor-bridge';
26
27
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
@@ -416,6 +417,7 @@ export default function InstallmentOptionsSection({
416
417
  }: InstallmentOptionsSectionProps) {
417
418
  const themeSettings = useThemeSettingsContext();
418
419
  const { t } = useLocalization();
420
+ const localize = useLocalizeHref();
419
421
  const isMobile = isNarrowBreakpoint(currentBreakpoint);
420
422
 
421
423
  const maxWidth = getResponsiveValue(
@@ -833,7 +835,7 @@ export default function InstallmentOptionsSection({
833
835
  </p>
834
836
  {productUrl && productUrl !== '#' && (
835
837
  <a
836
- href={productUrl}
838
+ href={localize(productUrl)}
837
839
  className="text-xs font-semibold text-[#0f172a] underline underline-offset-4"
838
840
  >
839
841
  View product
@@ -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
 
6
6
  import { partAttrs } from '../utils';
7
7
  import { useOverlay } from '../hooks/use-overlay';