@akinon/pz-theme 2.0.132-beta.0 → 2.0.132

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,12 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.132-beta.0
3
+ ## 2.0.132
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.132-beta.0
9
- - @akinon/theme-contract@2.0.132-beta.0
7
+ - 6a296eb: ZERO-5161: resolve video clip and posters per storefront language
8
+ - @akinon/next@2.0.132
9
+ - @akinon/theme-contract@2.0.132
10
10
 
11
11
  ## 2.0.131
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.132-beta.0",
4
+ "version": "2.0.132",
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.132-beta.0",
11
+ "@akinon/next": "2.0.132",
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.132-beta.0",
16
+ "@akinon/theme-contract": "2.0.132",
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.132-beta.0",
24
+ "@akinon/next": "2.0.132",
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",
@@ -375,7 +375,10 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
375
375
  // The literal string rather than the constant: the editor matches it by
376
376
  // name and drops a name it does not know, so this pins the wire value a
377
377
  // rename would silently break.
378
- expect(frames()[0].actionCapabilities).toEqual(['add-to-cart-after-add']);
378
+ expect(frames()[0].actionCapabilities).toEqual([
379
+ 'add-to-cart-after-add',
380
+ 'localized-video'
381
+ ]);
379
382
  });
380
383
 
381
384
  it('keeps the action capability on the designer re-announce too', () => {
@@ -401,8 +404,8 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
401
404
  // panel back to "On Click" halfway through a session.
402
405
  expect(postMessage).toHaveBeenCalledTimes(2);
403
406
  expect(frames().map((frame) => frame.actionCapabilities)).toEqual([
404
- ['add-to-cart-after-add'],
405
- ['add-to-cart-after-add']
407
+ ['add-to-cart-after-add', 'localized-video'],
408
+ ['add-to-cart-after-add', 'localized-video']
406
409
  ]);
407
410
  });
408
411
 
@@ -31,8 +31,9 @@
31
31
  * the placeholder registry announces this, and it must not have to pull a
32
32
  * client component in to say one list of strings.
33
33
  */
34
- export type ActionCapability = 'add-to-cart-after-add';
34
+ export type ActionCapability = 'add-to-cart-after-add' | 'localized-video';
35
35
 
36
36
  export const ACTION_CAPABILITIES: readonly ActionCapability[] = [
37
- 'add-to-cart-after-add'
37
+ 'add-to-cart-after-add',
38
+ 'localized-video'
38
39
  ];
@@ -0,0 +1,204 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ let localizationValue: Record<string, unknown> = {};
5
+
6
+ jest.mock('@akinon/next/hooks', () => ({
7
+ __esModule: true,
8
+ useLocalization: () => localizationValue
9
+ }));
10
+
11
+ import VideoBlock from '../video-block';
12
+ import type { BlockRendererProps } from '../block-renderer-registry';
13
+
14
+ const setLocalization = (
15
+ locale: string,
16
+ locales: string[] = ['tr', 'en'],
17
+ defaultLocaleValue = 'tr'
18
+ ) => {
19
+ localizationValue = {
20
+ locale,
21
+ locales: locales.map((value) => ({ value, label: value.toUpperCase() })),
22
+ defaultLocaleValue
23
+ };
24
+ };
25
+
26
+ const TR = 'https://cdn.example.com/hero-tr.mp4';
27
+ const EN = 'https://cdn.example.com/hero-en.mp4';
28
+ const TR_MOBILE = 'https://cdn.example.com/hero-tr-mobile.mp4';
29
+ const EN_MOBILE = 'https://cdn.example.com/hero-en-mobile.mp4';
30
+ const TR_POSTER = 'https://cdn.example.com/poster-tr.jpg';
31
+ const EN_POSTER = 'https://cdn.example.com/poster-en.jpg';
32
+
33
+ const mount = (
34
+ value: unknown,
35
+ properties: Record<string, unknown> = {},
36
+ currentBreakpoint = 'desktop'
37
+ ) =>
38
+ render(
39
+ <VideoBlock
40
+ {...({
41
+ block: { id: 'v', type: 'video', value, properties },
42
+ placeholderId: 'p1',
43
+ sectionId: 's1',
44
+ isDesigner: false,
45
+ currentBreakpoint
46
+ } as unknown as BlockRendererProps)}
47
+ />
48
+ ).container;
49
+
50
+ const srcOf = (
51
+ value: unknown,
52
+ properties: Record<string, unknown> = {},
53
+ currentBreakpoint = 'desktop'
54
+ ) =>
55
+ mount(value, properties, currentBreakpoint)
56
+ .querySelector('video')
57
+ ?.getAttribute('src') ?? null;
58
+
59
+ const posterOf = (
60
+ properties: Record<string, unknown>,
61
+ currentBreakpoint = 'desktop'
62
+ ) =>
63
+ mount(TR, properties, currentBreakpoint)
64
+ .querySelector('video')
65
+ ?.getAttribute('poster') ?? null;
66
+
67
+ const withViewport = (narrow: boolean, run: () => void) => {
68
+ const original = window.matchMedia;
69
+ window.matchMedia = ((query: string) => ({
70
+ matches: narrow && query.includes('max-width'),
71
+ media: query,
72
+ onchange: null,
73
+ addListener: () => {},
74
+ removeListener: () => {},
75
+ addEventListener: () => {},
76
+ removeEventListener: () => {},
77
+ dispatchEvent: () => false
78
+ })) as unknown as typeof window.matchMedia;
79
+ try {
80
+ run();
81
+ } finally {
82
+ window.matchMedia = original;
83
+ }
84
+ };
85
+
86
+ beforeEach(() => setLocalization('tr'));
87
+
88
+ describe('the clip, per language', () => {
89
+ it('keeps playing a plain address exactly as before', () => {
90
+ expect(srcOf(TR)).toBe(TR);
91
+
92
+ setLocalization('en');
93
+ expect(srcOf(TR)).toBe(TR);
94
+ });
95
+
96
+ it('picks the entry for the page’s language from a per-language map', () => {
97
+ expect(srcOf({ tr: TR, en: EN })).toBe(TR);
98
+
99
+ setLocalization('en');
100
+ expect(srcOf({ tr: TR, en: EN })).toBe(EN);
101
+ });
102
+
103
+ it('falls back to the default language, then to the first clip there is', () => {
104
+ setLocalization('en');
105
+ expect(srcOf({ tr: TR, en: '' })).toBe(TR);
106
+
107
+ setLocalization('en', ['tr', 'en'], 'de');
108
+ expect(srcOf({ tr: TR, en: '' })).toBe(TR);
109
+ });
110
+
111
+ it('reads the map the storefront receives serialised in a widget attribute', () => {
112
+ setLocalization('en');
113
+ expect(srcOf(JSON.stringify({ tr: TR, en: EN }))).toBe(EN);
114
+ });
115
+
116
+ it('shows nothing rather than breaking when the map names languages the store lacks', () => {
117
+ setLocalization('de', ['de']);
118
+ const container = mount({ tr: TR, en: EN });
119
+
120
+ expect(container.querySelector('video')).toBeNull();
121
+ expect(container.querySelector('iframe')).toBeNull();
122
+ });
123
+
124
+ it('resolves an embed per language too', () => {
125
+ const value = {
126
+ tr: 'https://www.youtube.com/watch?v=trclip01',
127
+ en: 'https://www.youtube.com/watch?v=enclip01'
128
+ };
129
+
130
+ expect(mount(value).querySelector('iframe')?.getAttribute('src')).toContain(
131
+ 'trclip01'
132
+ );
133
+
134
+ setLocalization('en');
135
+ expect(mount(value).querySelector('iframe')?.getAttribute('src')).toContain(
136
+ 'enclip01'
137
+ );
138
+ });
139
+ });
140
+
141
+ describe('the mobile clip, per language', () => {
142
+ const mobile = (mobileUrl: unknown) => ({
143
+ useDifferentMobileVideo: true,
144
+ mobileUrl
145
+ });
146
+
147
+ it('keeps honouring a plain address and the per-breakpoint shape older JSON holds', () => {
148
+ withViewport(true, () => {
149
+ expect(srcOf(TR, mobile(TR_MOBILE), 'mobile')).toBe(TR_MOBILE);
150
+ expect(srcOf(TR, mobile({ desktop: TR_MOBILE }), 'mobile')).toBe(
151
+ TR_MOBILE
152
+ );
153
+ });
154
+ });
155
+
156
+ it('picks the mobile clip for the page’s language', () => {
157
+ withViewport(true, () => {
158
+ expect(
159
+ srcOf(TR, mobile({ tr: TR_MOBILE, en: EN_MOBILE }), 'mobile')
160
+ ).toBe(TR_MOBILE);
161
+
162
+ setLocalization('en');
163
+ expect(
164
+ srcOf(EN, mobile({ tr: TR_MOBILE, en: EN_MOBILE }), 'mobile')
165
+ ).toBe(EN_MOBILE);
166
+ });
167
+ });
168
+
169
+ it('leaves the desktop clip alone on a wide screen', () => {
170
+ setLocalization('en');
171
+ withViewport(false, () => {
172
+ expect(
173
+ srcOf({ tr: TR, en: EN }, mobile({ tr: TR_MOBILE, en: EN_MOBILE }))
174
+ ).toBe(EN);
175
+ });
176
+ });
177
+ });
178
+
179
+ describe('the poster, per language', () => {
180
+ it('keeps a plain poster as it is', () => {
181
+ expect(posterOf({ poster: TR_POSTER })).toBe(TR_POSTER);
182
+ });
183
+
184
+ it('picks the poster for the page’s language, on desktop and on the phone', () => {
185
+ expect(posterOf({ poster: { tr: TR_POSTER, en: EN_POSTER } })).toBe(
186
+ TR_POSTER
187
+ );
188
+
189
+ setLocalization('en');
190
+ expect(posterOf({ poster: { tr: TR_POSTER, en: EN_POSTER } })).toBe(
191
+ EN_POSTER
192
+ );
193
+ expect(
194
+ posterOf(
195
+ {
196
+ useDifferentMobileVideo: true,
197
+ mobileUrl: EN_MOBILE,
198
+ mobilePoster: { tr: TR_POSTER, en: EN_POSTER }
199
+ },
200
+ 'mobile'
201
+ )
202
+ ).toBe(EN_POSTER);
203
+ });
204
+ });
@@ -9,6 +9,11 @@ import {
9
9
  partAttrs
10
10
  } from '../utils';
11
11
  import { BlockRendererProps } from './block-renderer-registry';
12
+ import {
13
+ resolveLocalizedValue,
14
+ useValueLocale,
15
+ type ValueLocale
16
+ } from '../utils/localized-value';
12
17
 
13
18
  /**
14
19
  * What an author's URL turned out to be.
@@ -311,6 +316,17 @@ const setAtAnyBreakpoint = (value: unknown): boolean => {
311
316
  };
312
317
 
313
318
  /** The first address a per-breakpoint value carries, asked the same way. */
319
+ const localizedMedia = (
320
+ value: unknown,
321
+ currentBreakpoint: string,
322
+ valueLocale: ValueLocale
323
+ ): string => {
324
+ const localized = resolveLocalizedValue(value, valueLocale);
325
+ const atBreakpoint = getResponsiveValue(localized, currentBreakpoint, '');
326
+ const resolved = resolveLocalizedValue(atBreakpoint, valueLocale);
327
+ return typeof resolved === 'string' ? resolved : '';
328
+ };
329
+
314
330
  const firstUrlAtAnyBreakpoint = (value: unknown): string => {
315
331
  if (typeof value === 'string') return value;
316
332
  if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
@@ -334,7 +350,11 @@ const VideoBlock = ({
334
350
  isDesigner = false,
335
351
  currentBreakpoint = 'desktop'
336
352
  }: BlockRendererProps) => {
337
- const desktopUrl = block.value as string;
353
+ const valueLocale = useValueLocale();
354
+ const localizedValue = resolveLocalizedValue(block.value, valueLocale);
355
+ const desktopUrl = (
356
+ typeof localizedValue === 'string' ? localizedValue : block.value
357
+ ) as string;
338
358
  const properties = (block.properties || {}) as VideoProperties;
339
359
 
340
360
  /**
@@ -361,11 +381,11 @@ const VideoBlock = ({
361
381
  currentBreakpoint,
362
382
  false
363
383
  );
364
- const mobileUrl = getResponsiveValue(
384
+ const mobileUrl = localizedMedia(
365
385
  properties.mobileUrl,
366
386
  currentBreakpoint,
367
- ''
368
- ) as string;
387
+ valueLocale
388
+ );
369
389
  const videoUrl =
370
390
  isMobile && useDifferentMobileVideo && mobileUrl ? mobileUrl : desktopUrl;
371
391
 
@@ -400,7 +420,9 @@ const VideoBlock = ({
400
420
  * the element with no source at all and the effect below names the one file
401
421
  * this viewport needs — see it for why the server cannot decide this.
402
422
  */
403
- const mobileUrlAnywhere = firstUrlAtAnyBreakpoint(properties.mobileUrl);
423
+ const mobileUrlAnywhere = firstUrlAtAnyBreakpoint(
424
+ resolveLocalizedValue(properties.mobileUrl, valueLocale)
425
+ );
404
426
  const hasDistinctMobileVideo =
405
427
  setAtAnyBreakpoint(properties.useDifferentMobileVideo) &&
406
428
  !!mobileUrlAnywhere &&
@@ -430,16 +452,16 @@ const VideoBlock = ({
430
452
  const nativeControls =
431
453
  getResponsiveValue(properties.controls, currentBreakpoint, true) !== false;
432
454
 
433
- const desktopPoster = getResponsiveValue(
455
+ const desktopPoster = localizedMedia(
434
456
  properties.poster,
435
457
  currentBreakpoint,
436
- ''
437
- ) as string;
438
- const mobilePoster = getResponsiveValue(
458
+ valueLocale
459
+ );
460
+ const mobilePoster = localizedMedia(
439
461
  properties.mobilePoster,
440
462
  currentBreakpoint,
441
- ''
442
- ) as string;
463
+ valueLocale
464
+ );
443
465
  const posterUrl =
444
466
  isMobile && useDifferentMobileVideo && mobilePoster
445
467
  ? mobilePoster