@akinon/pz-theme 2.0.111-beta.0 → 2.0.111
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 +4 -4
- package/package.json +4 -4
- package/src/blocks/__tests__/image-block-locale.test.tsx +232 -0
- package/src/blocks/image-block.tsx +32 -6
- package/src/sections/countdown-campaign-banner-section.tsx +10 -2
- package/src/sections/coupon-banner-section.tsx +10 -2
- package/src/utils/localized-value.ts +94 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.111
|
|
3
|
+
## 2.0.111
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- @akinon/next@2.0.111
|
|
9
|
-
- @akinon/theme-contract@2.0.111
|
|
7
|
+
- 6f73835: ZERO-5085: Let theme editor images differ per locale
|
|
8
|
+
- @akinon/next@2.0.111
|
|
9
|
+
- @akinon/theme-contract@2.0.111
|
|
10
10
|
|
|
11
11
|
## 2.0.110
|
|
12
12
|
|
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.111
|
|
4
|
+
"version": "2.0.111",
|
|
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.111
|
|
11
|
+
"@akinon/next": "2.0.111",
|
|
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.111
|
|
16
|
+
"@akinon/theme-contract": "2.0.111",
|
|
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.111
|
|
24
|
+
"@akinon/next": "2.0.111",
|
|
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,232 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderToString } from 'react-dom/server';
|
|
3
|
+
import { render } from '@testing-library/react';
|
|
4
|
+
|
|
5
|
+
// Per-test localization: the shared mapper mock is fixed to `en-us`, and these
|
|
6
|
+
// cases need to switch the language the page is showing.
|
|
7
|
+
let localizationValue: Record<string, unknown> = {};
|
|
8
|
+
jest.mock('@akinon/next/hooks', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
useLocalization: () => localizationValue
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import ImageBlock from '../image-block';
|
|
14
|
+
import {
|
|
15
|
+
isLocalizedValue,
|
|
16
|
+
resolveLocalizedValue
|
|
17
|
+
} from '../../utils/localized-value';
|
|
18
|
+
import type { BlockRendererProps } from '../block-renderer-registry';
|
|
19
|
+
|
|
20
|
+
const TR = 'https://example.com/tr.jpg';
|
|
21
|
+
const EN = 'https://example.com/en.jpg';
|
|
22
|
+
|
|
23
|
+
const setLocalization = (
|
|
24
|
+
locale: string,
|
|
25
|
+
locales: string[] = ['tr', 'en'],
|
|
26
|
+
defaultLocaleValue = 'tr'
|
|
27
|
+
) => {
|
|
28
|
+
localizationValue = {
|
|
29
|
+
locale,
|
|
30
|
+
locales: locales.map((value) => ({ value, label: value.toUpperCase() })),
|
|
31
|
+
defaultLocaleValue
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const element = (
|
|
36
|
+
block: Record<string, unknown>,
|
|
37
|
+
extra: Partial<BlockRendererProps> = {}
|
|
38
|
+
) => (
|
|
39
|
+
<ImageBlock
|
|
40
|
+
{...({
|
|
41
|
+
block: { id: 'img', type: 'image', ...block }
|
|
42
|
+
} as unknown as BlockRendererProps)}
|
|
43
|
+
{...extra}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const srcOf = (
|
|
48
|
+
block: Record<string, unknown>,
|
|
49
|
+
extra: Partial<BlockRendererProps> = {}
|
|
50
|
+
) =>
|
|
51
|
+
render(element(block, extra))
|
|
52
|
+
.container.querySelector('img')
|
|
53
|
+
?.getAttribute('src') ?? null;
|
|
54
|
+
|
|
55
|
+
const markupOf = (
|
|
56
|
+
block: Record<string, unknown>,
|
|
57
|
+
extra: Partial<BlockRendererProps> = {}
|
|
58
|
+
) => render(element(block, extra)).container.innerHTML;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => setLocalization('tr'));
|
|
61
|
+
|
|
62
|
+
describe('isLocalizedValue', () => {
|
|
63
|
+
const locales = ['tr', 'en'];
|
|
64
|
+
|
|
65
|
+
it('is true only when a key is one of the known locales', () => {
|
|
66
|
+
expect(isLocalizedValue({ tr: TR, en: EN }, locales)).toBe(true);
|
|
67
|
+
expect(isLocalizedValue({ en: EN }, locales)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('never reads the existing image shapes as localized', () => {
|
|
71
|
+
expect(isLocalizedValue(TR, locales)).toBe(false);
|
|
72
|
+
expect(isLocalizedValue({ url: TR }, locales)).toBe(false);
|
|
73
|
+
expect(
|
|
74
|
+
isLocalizedValue({ desktop: TR, mobile: EN, tablet: EN }, locales)
|
|
75
|
+
).toBe(false);
|
|
76
|
+
expect(isLocalizedValue([TR, EN], locales)).toBe(false);
|
|
77
|
+
expect(isLocalizedValue(null, locales)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('does not guess from key shape: locale-looking keys need to be known', () => {
|
|
81
|
+
expect(isLocalizedValue({ tr: TR, en: EN }, ['tr-tr', 'en-us'])).toBe(
|
|
82
|
+
false
|
|
83
|
+
);
|
|
84
|
+
expect(isLocalizedValue({ tr: TR, en: EN }, [])).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('resolveLocalizedValue', () => {
|
|
89
|
+
const ctx = {
|
|
90
|
+
locale: 'en',
|
|
91
|
+
defaultLocale: 'tr',
|
|
92
|
+
locales: ['tr', 'en', 'de']
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
it('picks the current locale, then the default, then the first entry', () => {
|
|
96
|
+
expect(resolveLocalizedValue({ tr: TR, en: EN }, ctx)).toBe(EN);
|
|
97
|
+
expect(resolveLocalizedValue({ tr: TR }, ctx)).toBe(TR);
|
|
98
|
+
expect(resolveLocalizedValue({ de: 'de.jpg' }, ctx)).toBe('de.jpg');
|
|
99
|
+
expect(resolveLocalizedValue({ tr: TR, en: '' }, ctx)).toBe(TR);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns a non-localized value as the same reference', () => {
|
|
103
|
+
const breakpoints = { desktop: TR, mobile: EN };
|
|
104
|
+
const picker = { url: TR };
|
|
105
|
+
|
|
106
|
+
expect(resolveLocalizedValue(TR, ctx)).toBe(TR);
|
|
107
|
+
expect(resolveLocalizedValue(breakpoints, ctx)).toBe(breakpoints);
|
|
108
|
+
expect(resolveLocalizedValue(picker, ctx)).toBe(picker);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('ImageBlock - per-locale sources', () => {
|
|
113
|
+
it('renders the image of the language being shown', () => {
|
|
114
|
+
setLocalization('en');
|
|
115
|
+
expect(srcOf({ value: { tr: TR, en: EN } })).toBe(EN);
|
|
116
|
+
|
|
117
|
+
setLocalization('tr');
|
|
118
|
+
expect(srcOf({ value: { tr: TR, en: EN } })).toBe(TR);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('falls back to the default locale, then to the first entry', () => {
|
|
122
|
+
setLocalization('en', ['tr', 'en', 'de'], 'tr');
|
|
123
|
+
expect(srcOf({ value: { de: 'https://example.com/de.jpg', tr: TR } })).toBe(
|
|
124
|
+
TR
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
setLocalization('en', ['tr', 'en', 'de'], 'tr');
|
|
128
|
+
expect(srcOf({ value: { de: 'https://example.com/de.jpg' } })).toBe(
|
|
129
|
+
'https://example.com/de.jpg'
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('unwraps { url } and breakpoint maps inside a locale entry', () => {
|
|
134
|
+
setLocalization('en');
|
|
135
|
+
expect(srcOf({ value: { tr: { url: TR }, en: { url: EN } } })).toBe(EN);
|
|
136
|
+
|
|
137
|
+
const value = {
|
|
138
|
+
tr: { desktop: TR, mobile: 'https://example.com/tr-m.jpg' },
|
|
139
|
+
en: { desktop: EN, mobile: 'https://example.com/en-m.jpg' }
|
|
140
|
+
};
|
|
141
|
+
expect(srcOf({ value })).toBe(EN);
|
|
142
|
+
expect(srcOf({ value }, { currentBreakpoint: 'mobile' })).toBe(
|
|
143
|
+
'https://example.com/en-m.jpg'
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('localizes the phone override on the storefront <picture>', () => {
|
|
148
|
+
setLocalization('en');
|
|
149
|
+
const { container } = render(
|
|
150
|
+
element({
|
|
151
|
+
value: { tr: TR, en: EN },
|
|
152
|
+
properties: {
|
|
153
|
+
imageMobile: {
|
|
154
|
+
tr: 'https://example.com/tr-phone.jpg',
|
|
155
|
+
en: 'https://example.com/en-phone.jpg'
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(container.querySelector('img')?.getAttribute('src')).toBe(EN);
|
|
162
|
+
expect(container.querySelector('source')?.getAttribute('srcset')).toBe(
|
|
163
|
+
'https://example.com/en-phone.jpg'
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('localizes the phone override on the canvas', () => {
|
|
168
|
+
setLocalization('tr');
|
|
169
|
+
expect(
|
|
170
|
+
srcOf(
|
|
171
|
+
{
|
|
172
|
+
value: { tr: TR, en: EN },
|
|
173
|
+
properties: {
|
|
174
|
+
imageMobile: {
|
|
175
|
+
tr: 'https://example.com/tr-phone.jpg',
|
|
176
|
+
en: 'https://example.com/en-phone.jpg'
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
{ isDesigner: true, currentBreakpoint: 'mobile' }
|
|
181
|
+
)
|
|
182
|
+
).toBe('https://example.com/tr-phone.jpg');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('resolves the locale in the server render', () => {
|
|
186
|
+
setLocalization('en');
|
|
187
|
+
const html = renderToString(element({ value: { tr: TR, en: EN } }));
|
|
188
|
+
|
|
189
|
+
expect(html).toContain(`src="${EN}"`);
|
|
190
|
+
expect(html).not.toContain(TR);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('non-localized values render exactly as before', () => {
|
|
194
|
+
// An empty locale list is the pre-feature renderer: nothing can be
|
|
195
|
+
// localized, so every value goes straight to the old unwrap.
|
|
196
|
+
const before = (
|
|
197
|
+
block: Record<string, unknown>,
|
|
198
|
+
extra: Partial<BlockRendererProps> = {}
|
|
199
|
+
) => {
|
|
200
|
+
setLocalization('tr', []);
|
|
201
|
+
const html = markupOf(block, extra);
|
|
202
|
+
setLocalization('tr');
|
|
203
|
+
return html;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const cases: Array<[string, Record<string, unknown>]> = [
|
|
207
|
+
['a URL string', { value: TR }],
|
|
208
|
+
['a { url } value', { value: { url: TR } }],
|
|
209
|
+
[
|
|
210
|
+
'a breakpoint map',
|
|
211
|
+
{ value: { desktop: TR, mobile: 'https://example.com/m.jpg' } }
|
|
212
|
+
],
|
|
213
|
+
[
|
|
214
|
+
'a phone override',
|
|
215
|
+
{ value: TR, properties: { imageMobile: 'https://example.com/m.jpg' } }
|
|
216
|
+
],
|
|
217
|
+
// Locale-looking keys that are not the storefront's locales stay on
|
|
218
|
+
// the old path (which takes the first string it finds).
|
|
219
|
+
['unknown locale-looking keys', { value: { de: TR, fr: EN } }]
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
it.each(cases)('%s', (_label, block) => {
|
|
223
|
+
for (const extra of [
|
|
224
|
+
{},
|
|
225
|
+
{ currentBreakpoint: 'mobile' },
|
|
226
|
+
{ isDesigner: true, currentBreakpoint: 'mobile' }
|
|
227
|
+
] as Array<Partial<BlockRendererProps>>) {
|
|
228
|
+
expect(markupOf(block, extra)).toBe(before(block, extra));
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -13,6 +13,11 @@ import {
|
|
|
13
13
|
PHONE_ONLY_MEDIA_CONDITION,
|
|
14
14
|
TABLET_ONLY_MEDIA_CONDITION
|
|
15
15
|
} from '../utils/breakpoints';
|
|
16
|
+
import {
|
|
17
|
+
type ValueLocale,
|
|
18
|
+
resolveLocalizedValue,
|
|
19
|
+
useValueLocale
|
|
20
|
+
} from '../utils/localized-value';
|
|
16
21
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
17
22
|
|
|
18
23
|
const pickImageSource = (value: unknown, currentBreakpoint: string): string => {
|
|
@@ -82,8 +87,15 @@ const isValidImageSource = (src: string): boolean => {
|
|
|
82
87
|
* save path flattens an `image` block's `value` to a single string anyway, so a
|
|
83
88
|
* per-breakpoint map could never survive a round trip there.
|
|
84
89
|
*/
|
|
85
|
-
const resolveOverride = (
|
|
86
|
-
|
|
90
|
+
const resolveOverride = (
|
|
91
|
+
value: unknown,
|
|
92
|
+
currentBreakpoint: string,
|
|
93
|
+
valueLocale: ValueLocale
|
|
94
|
+
): string => {
|
|
95
|
+
const raw = pickImageSource(
|
|
96
|
+
resolveLocalizedValue(value, valueLocale),
|
|
97
|
+
currentBreakpoint
|
|
98
|
+
);
|
|
87
99
|
const url = normalizeImageSource(raw);
|
|
88
100
|
return isValidImageSource(url) ? url : '';
|
|
89
101
|
};
|
|
@@ -137,8 +149,20 @@ const ImageBlock = ({
|
|
|
137
149
|
isDesigner = false,
|
|
138
150
|
currentBreakpoint = 'desktop'
|
|
139
151
|
}: BlockRendererProps) => {
|
|
152
|
+
/*
|
|
153
|
+
* Every source (the main value and both overrides) may be a per-locale map.
|
|
154
|
+
* The language is picked first and the result goes through the unwrap below
|
|
155
|
+
* unchanged, so a locale entry can itself be a URL, `{ url }` or a
|
|
156
|
+
* breakpoint map, and a value that is not localized is read as it always
|
|
157
|
+
* was.
|
|
158
|
+
*/
|
|
159
|
+
const valueLocale = useValueLocale();
|
|
160
|
+
|
|
140
161
|
const imageValue = useMemo(() => {
|
|
141
|
-
const rawUrl = pickImageSource(
|
|
162
|
+
const rawUrl = pickImageSource(
|
|
163
|
+
resolveLocalizedValue(block.value, valueLocale),
|
|
164
|
+
currentBreakpoint
|
|
165
|
+
);
|
|
142
166
|
const url = normalizeImageSource(rawUrl);
|
|
143
167
|
const alt = String(block.properties?.alt || '');
|
|
144
168
|
|
|
@@ -146,17 +170,19 @@ const ImageBlock = ({
|
|
|
146
170
|
url: isValidImageSource(url) ? url : '',
|
|
147
171
|
alt
|
|
148
172
|
};
|
|
149
|
-
}, [block.value, block.properties?.alt, currentBreakpoint]);
|
|
173
|
+
}, [block.value, block.properties?.alt, currentBreakpoint, valueLocale]);
|
|
150
174
|
|
|
151
175
|
const { url: desktopSrc, alt } = imageValue;
|
|
152
176
|
|
|
153
177
|
const imageTablet = resolveOverride(
|
|
154
178
|
block.properties?.imageTablet,
|
|
155
|
-
currentBreakpoint
|
|
179
|
+
currentBreakpoint,
|
|
180
|
+
valueLocale
|
|
156
181
|
);
|
|
157
182
|
const imageMobile = resolveOverride(
|
|
158
183
|
block.properties?.imageMobile,
|
|
159
|
-
currentBreakpoint
|
|
184
|
+
currentBreakpoint,
|
|
185
|
+
valueLocale
|
|
160
186
|
);
|
|
161
187
|
|
|
162
188
|
const pictureSources = useMemo(
|
|
@@ -12,6 +12,10 @@ import {
|
|
|
12
12
|
getResponsiveValue,
|
|
13
13
|
resolveThemeCssVariables
|
|
14
14
|
} from '../utils';
|
|
15
|
+
import {
|
|
16
|
+
resolveLocalizedValue,
|
|
17
|
+
useValueLocale
|
|
18
|
+
} from '../utils/localized-value';
|
|
15
19
|
import { Section } from '../theme-section';
|
|
16
20
|
|
|
17
21
|
import { postToEditor } from '../editor-bridge';
|
|
@@ -179,6 +183,7 @@ const CountdownCampaignBannerSection: React.FC<
|
|
|
179
183
|
selectedBlockId = null
|
|
180
184
|
}) => {
|
|
181
185
|
const themeSettings = useThemeSettingsContext();
|
|
186
|
+
const valueLocale = useValueLocale();
|
|
182
187
|
|
|
183
188
|
const sortedBlocks = [...(section.blocks || [])]
|
|
184
189
|
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
@@ -272,10 +277,13 @@ const CountdownCampaignBannerSection: React.FC<
|
|
|
272
277
|
);
|
|
273
278
|
const backgroundImageSrc = useMemo(() => {
|
|
274
279
|
if (!backgroundBlock) return '';
|
|
275
|
-
const raw = pickImageSource(
|
|
280
|
+
const raw = pickImageSource(
|
|
281
|
+
resolveLocalizedValue(backgroundBlock.value, valueLocale),
|
|
282
|
+
currentBreakpoint
|
|
283
|
+
);
|
|
276
284
|
const normalized = normalizeImageSource(raw);
|
|
277
285
|
return hasValidImageSource(normalized) ? normalized : '';
|
|
278
|
-
}, [backgroundBlock, currentBreakpoint]);
|
|
286
|
+
}, [backgroundBlock, currentBreakpoint, valueLocale]);
|
|
279
287
|
|
|
280
288
|
const overlayColor = resolveThemeCssVariables(
|
|
281
289
|
String(
|
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
resolveThemeCssVariables
|
|
15
15
|
} from '../utils';
|
|
16
16
|
import { isNarrowBreakpoint } from '../utils/breakpoints';
|
|
17
|
+
import {
|
|
18
|
+
resolveLocalizedValue,
|
|
19
|
+
useValueLocale
|
|
20
|
+
} from '../utils/localized-value';
|
|
17
21
|
import { Section } from '../theme-section';
|
|
18
22
|
|
|
19
23
|
import { postToEditor } from '../editor-bridge';
|
|
@@ -198,6 +202,7 @@ const CouponBannerSection: React.FC<CouponBannerSectionProps> = ({
|
|
|
198
202
|
selectedBlockId = null
|
|
199
203
|
}) => {
|
|
200
204
|
const themeSettings = useThemeSettingsContext();
|
|
205
|
+
const valueLocale = useValueLocale();
|
|
201
206
|
|
|
202
207
|
const sortedBlocks = [...(section.blocks || [])]
|
|
203
208
|
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
@@ -349,10 +354,13 @@ const CouponBannerSection: React.FC<CouponBannerSectionProps> = ({
|
|
|
349
354
|
|
|
350
355
|
const backgroundImageSrc = useMemo(() => {
|
|
351
356
|
if (!backgroundBlock) return '';
|
|
352
|
-
const raw = pickImageSource(
|
|
357
|
+
const raw = pickImageSource(
|
|
358
|
+
resolveLocalizedValue(backgroundBlock.value, valueLocale),
|
|
359
|
+
currentBreakpoint
|
|
360
|
+
);
|
|
353
361
|
const normalized = normalizeImageSource(raw);
|
|
354
362
|
return hasValidImageSource(normalized) ? normalized : '';
|
|
355
|
-
}, [backgroundBlock, currentBreakpoint]);
|
|
363
|
+
}, [backgroundBlock, currentBreakpoint, valueLocale]);
|
|
356
364
|
const hasBackgroundImage = Boolean(backgroundBlock && backgroundImageSrc);
|
|
357
365
|
|
|
358
366
|
const overlayColor = resolveThemeCssVariables(
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The language a value is read in, plus the storefront's own locale codes.
|
|
6
|
+
*
|
|
7
|
+
* `locales` is the list a key has to appear in for a value to count as
|
|
8
|
+
* per-locale at all (see `isLocalizedValue`), so it comes from the storefront's
|
|
9
|
+
* settings rather than from anything the value itself looks like.
|
|
10
|
+
*/
|
|
11
|
+
export interface ValueLocale {
|
|
12
|
+
locale?: string;
|
|
13
|
+
defaultLocale?: string;
|
|
14
|
+
locales: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const hasEntry = (entry: unknown): boolean =>
|
|
18
|
+
entry !== undefined && entry !== null && entry !== '';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether `value` is a per-locale map such as `{ tr: '…', en: '…' }`.
|
|
22
|
+
*
|
|
23
|
+
* Only a key that is one of the storefront's locale codes makes it one. The
|
|
24
|
+
* key's shape is never enough: a breakpoint map (`{ desktop, mobile }`) and the
|
|
25
|
+
* picker's `{ url }` are objects too, and guessing from "looks like a language
|
|
26
|
+
* code" would read either of them as a locale map and blank a published image.
|
|
27
|
+
* With no locales known (no localization provider above the block) nothing is
|
|
28
|
+
* localized, so the value renders exactly as it did before locales existed.
|
|
29
|
+
*/
|
|
30
|
+
export const isLocalizedValue = (
|
|
31
|
+
value: unknown,
|
|
32
|
+
locales: readonly string[]
|
|
33
|
+
): value is Record<string, unknown> =>
|
|
34
|
+
typeof value === 'object' &&
|
|
35
|
+
value !== null &&
|
|
36
|
+
!Array.isArray(value) &&
|
|
37
|
+
locales.length > 0 &&
|
|
38
|
+
Object.keys(value).some((key) => locales.includes(key));
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Picks the entry for the language being shown, before any other unwrap.
|
|
42
|
+
*
|
|
43
|
+
* Current locale, then the default locale, then the first entry — so a
|
|
44
|
+
* language the merchant has not given its own image shows the default one
|
|
45
|
+
* instead of nothing. The entry is returned as authored (a URL string, a
|
|
46
|
+
* breakpoint map or `{ url }`), because the caller's existing unwrap already
|
|
47
|
+
* knows every one of those shapes.
|
|
48
|
+
*
|
|
49
|
+
* A value that is not localized is returned as the same reference, which is
|
|
50
|
+
* what keeps every block published before this existed rendering byte for
|
|
51
|
+
* byte as it did.
|
|
52
|
+
*/
|
|
53
|
+
export const resolveLocalizedValue = (
|
|
54
|
+
value: unknown,
|
|
55
|
+
{ locale, defaultLocale, locales }: ValueLocale
|
|
56
|
+
): unknown => {
|
|
57
|
+
if (!isLocalizedValue(value, locales)) return value;
|
|
58
|
+
|
|
59
|
+
if (locale && hasEntry(value[locale])) return value[locale];
|
|
60
|
+
if (defaultLocale && hasEntry(value[defaultLocale])) {
|
|
61
|
+
return value[defaultLocale];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return Object.values(value).find(hasEntry);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The storefront's `ValueLocale`, read from the same provider the text block
|
|
69
|
+
* reads, so it resolves identically in the server render and after hydration.
|
|
70
|
+
*
|
|
71
|
+
* Only `locale`, `locales` and `defaultLocaleValue` are read: all three exist
|
|
72
|
+
* on the v1 core's `useLocalization` as well.
|
|
73
|
+
*/
|
|
74
|
+
export const useValueLocale = (): ValueLocale => {
|
|
75
|
+
const { locale, locales, defaultLocaleValue } = useLocalization();
|
|
76
|
+
|
|
77
|
+
const codes = useMemo(
|
|
78
|
+
() =>
|
|
79
|
+
(Array.isArray(locales) ? locales : [])
|
|
80
|
+
.map((entry) => (entry as { value?: unknown } | null)?.value)
|
|
81
|
+
.filter(
|
|
82
|
+
(value): value is string => typeof value === 'string' && value !== ''
|
|
83
|
+
),
|
|
84
|
+
[locales]
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const defaultLocale =
|
|
88
|
+
defaultLocaleValue || process.env.NEXT_PUBLIC_DEFAULT_LOCALE;
|
|
89
|
+
|
|
90
|
+
return useMemo(
|
|
91
|
+
() => ({ locale, defaultLocale, locales: codes }),
|
|
92
|
+
[locale, defaultLocale, codes]
|
|
93
|
+
);
|
|
94
|
+
};
|