@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.
- package/CHANGELOG.md +15 -35
- package/package.json +4 -4
- package/src/blocks/__tests__/locale-links.test.tsx +429 -0
- package/src/blocks/account-button-block.tsx +1 -1
- package/src/blocks/account-menu-block.tsx +9 -3
- package/src/blocks/applied-filters-block.tsx +7 -1
- package/src/blocks/auth-form-block.tsx +7 -4
- package/src/blocks/basket-items-block.tsx +1 -1
- package/src/blocks/basket-summary-block.tsx +1 -1
- package/src/blocks/button-block.tsx +5 -1
- package/src/blocks/carousel-item-block.tsx +1 -1
- package/src/blocks/coupon-wallet-block.tsx +3 -1
- package/src/blocks/facet-filters-block.tsx +14 -10
- package/src/blocks/favorite-button-block.tsx +1 -1
- package/src/blocks/favourites-grid-block.tsx +1 -1
- package/src/blocks/group-block.tsx +19 -6
- package/src/blocks/hamburger-menu-block.tsx +1 -1
- package/src/blocks/hotspot-block.tsx +3 -1
- package/src/blocks/hover-showcase-item-block.tsx +3 -1
- package/src/blocks/link-block.tsx +5 -22
- package/src/blocks/listing-breadcrumb-block.tsx +1 -1
- package/src/blocks/listing-pagination-block.tsx +11 -3
- package/src/blocks/mega-menu-block.tsx +9 -3
- package/src/blocks/mini-basket-block.tsx +1 -1
- package/src/blocks/nav-menu-block.tsx +9 -3
- package/src/blocks/oauth-buttons-block.tsx +4 -0
- package/src/blocks/order-detail-block.tsx +1 -1
- package/src/blocks/order-list-block.tsx +1 -1
- package/src/blocks/order-success-block.tsx +1 -1
- package/src/blocks/otp-form-block.tsx +4 -1
- package/src/blocks/payment-badges-block.tsx +1 -1
- package/src/blocks/product-breadcrumb-block.tsx +1 -1
- package/src/blocks/product-grid-block.tsx +1 -1
- package/src/blocks/product-recommendations-block.tsx +1 -1
- package/src/blocks/register-form-block.tsx +6 -3
- package/src/blocks/search-box-block.tsx +7 -1
- package/src/blocks/social-icons-list-block.tsx +1 -1
- package/src/blocks/social-links-block.tsx +1 -1
- package/src/blocks/sort-select-block.tsx +8 -2
- package/src/blocks/store-locator-block.tsx +3 -1
- package/src/blocks/text-block.tsx +8 -1
- package/src/blocks/variant-selector-block.tsx +6 -2
- package/src/blocks/wishlist-button-block.tsx +1 -1
- package/src/components/__tests__/theme-link.test.tsx +169 -0
- package/src/components/theme-link.tsx +36 -0
- package/src/custom/README-sections.md +6 -0
- package/src/custom/README.md +71 -0
- package/src/custom/__tests__/custom-entry-exports.test.tsx +13 -0
- package/src/custom/__tests__/theme-editable.test.tsx +179 -0
- package/src/custom/__tests__/use-theme-setting.test.tsx +101 -0
- package/src/custom/index.ts +16 -0
- package/src/custom/theme-editable.tsx +57 -4
- package/src/custom/use-theme-setting.ts +77 -10
- package/src/hooks/use-localize-href.ts +34 -0
- package/src/index.ts +12 -0
- package/src/sections/find-in-store-section.tsx +3 -1
- package/src/sections/hover-showcase-section.tsx +6 -2
- package/src/sections/installment-options-section.tsx +3 -1
- package/src/sections/slider-quick-view.tsx +1 -1
- package/src/utils/__tests__/localize-href.test.ts +328 -0
- 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
|
-
|
|
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:
|
|
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 '
|
|
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
|
-
|
|
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 '
|
|
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
|
package/src/custom/README.md
CHANGED
|
@@ -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(
|