@akinon/pz-theme 2.0.111-rc.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 CHANGED
@@ -1,40 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.111-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b1]
34
- - Updated dependencies [1595d45]
35
- - Updated dependencies [dca1281]
36
- - @akinon/next@2.0.111-rc.0
37
- - @akinon/theme-contract@2.0.111-rc.0
3
+ ## 2.0.111
4
+
5
+ ### Patch Changes
6
+
7
+ - 6f73835: ZERO-5085: Let theme editor images differ per locale
8
+ - @akinon/next@2.0.111
9
+ - @akinon/theme-contract@2.0.111
38
10
 
39
11
  ## 2.0.110
40
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-rc.0",
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-rc.0",
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-rc.0",
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-rc.0",
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 = (value: unknown, currentBreakpoint: string): string => {
86
- const raw = pickImageSource(value, currentBreakpoint);
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(block.value, currentBreakpoint);
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(backgroundBlock.value, currentBreakpoint);
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(backgroundBlock.value, currentBreakpoint);
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
+ };