@akinon/pz-theme 2.0.109-rc.0 → 2.0.110-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 CHANGED
@@ -1,40 +1,20 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.109-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.109-rc.0
37
- - @akinon/theme-contract@2.0.109-rc.0
3
+ ## 2.0.110-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.110-beta.0
9
+ - @akinon/theme-contract@2.0.110-beta.0
10
+
11
+ ## 2.0.109
12
+
13
+ ### Patch Changes
14
+
15
+ - 55ab88c: ZERO-5083: Keep the theme editor's Language dropdown in sync from pz-theme, so brands whose header no longer renders the template LanguageSelect show their real languages and follow the editor's language switch
16
+ - @akinon/next@2.0.109
17
+ - @akinon/theme-contract@2.0.109
38
18
 
39
19
  ## 2.0.108
40
20
 
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.109-rc.0",
4
+ "version": "2.0.110-beta.0",
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.109-rc.0",
11
+ "@akinon/next": "2.0.110-beta.0",
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.109-rc.0",
16
+ "@akinon/theme-contract": "2.0.110-beta.0",
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.109-rc.0",
24
+ "@akinon/next": "2.0.110-beta.0",
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,272 @@
1
+ import React from 'react';
2
+ import { act, fireEvent, render } from '@testing-library/react';
3
+
4
+ import { __resetEditorBridgeForTests } from '../editor-bridge';
5
+ import {
6
+ __resetEditorLocaleBridgeForTests,
7
+ EditorLocaleBridge
8
+ } from '../editor-locale-bridge';
9
+ import ThemePlaceholderWrapper from '../theme-placeholder-wrapper';
10
+
11
+ /**
12
+ * The editor's Language dropdown is filled by the preview, and the preview has
13
+ * to follow the dropdown back. That used to be the app-template's
14
+ * LanguageSelect's job, so a brand whose header no longer rendered it left the
15
+ * editor on a hard-coded English. The bridge now rides on every placeholder,
16
+ * and a page has several of them, so "once per page" is the property that
17
+ * matters most here, next to the two it must never break: no traffic outside
18
+ * the editor, and no switch ordered by anyone but the editor.
19
+ */
20
+
21
+ const EDITOR_ORIGIN = 'http://localhost:5174';
22
+
23
+ const SETTINGS_LOCALES = [
24
+ { value: 'en', label: 'English', apiValue: 'en-us', rtl: false },
25
+ { value: 'tr', label: 'Türkçe', apiValue: 'tr-tr', rtl: false }
26
+ ];
27
+
28
+ const mockLocalization = {
29
+ locale: 'tr',
30
+ locales: SETTINGS_LOCALES as Array<Record<string, unknown>>,
31
+ setLocale: jest.fn()
32
+ };
33
+
34
+ jest.mock('@akinon/next/hooks', () => ({
35
+ __esModule: true,
36
+ useLocalization: () => ({
37
+ t: (path: string) => path,
38
+ currency: 'TRY',
39
+ currencies: ['TRY'],
40
+ setCurrency: () => {},
41
+ ...mockLocalization
42
+ })
43
+ }));
44
+
45
+ const originalParent = window.parent;
46
+ const originalTop = window.top;
47
+
48
+ const postMessage = jest.fn();
49
+
50
+ const frameDocument = () => {
51
+ Object.defineProperty(window, 'top', {
52
+ configurable: true,
53
+ value: { name: 'designer' }
54
+ });
55
+ Object.defineProperty(window, 'parent', {
56
+ configurable: true,
57
+ value: { postMessage }
58
+ });
59
+ // Before the editor has spoken, the bridge finds it through the referrer.
60
+ Object.defineProperty(document, 'referrer', {
61
+ configurable: true,
62
+ value: `${EDITOR_ORIGIN}/builder`
63
+ });
64
+ };
65
+
66
+ const unframeDocument = () => {
67
+ Object.defineProperty(window, 'top', {
68
+ configurable: true,
69
+ value: originalTop
70
+ });
71
+ Object.defineProperty(window, 'parent', {
72
+ configurable: true,
73
+ value: originalParent
74
+ });
75
+ };
76
+
77
+ const localeFrames = () =>
78
+ postMessage.mock.calls
79
+ .map(([message]) => message)
80
+ .filter((message) => message?.type === 'LOCALE_UPDATE');
81
+
82
+ const editorSwitch = (locale: string, origin = EDITOR_ORIGIN) =>
83
+ act(() => {
84
+ fireEvent(
85
+ window,
86
+ new MessageEvent('message', {
87
+ origin,
88
+ data: { type: 'LOCALE_UPDATE', data: { locale, locales: [] } }
89
+ })
90
+ );
91
+ });
92
+
93
+ const placeholder = (slug: string) => (
94
+ <ThemePlaceholderWrapper
95
+ key={slug}
96
+ slug={slug}
97
+ initialSections={[]}
98
+ initialPlaceholderId={slug}
99
+ isDesignMode={false}
100
+ />
101
+ );
102
+
103
+ beforeEach(() => {
104
+ __resetEditorBridgeForTests();
105
+ __resetEditorLocaleBridgeForTests();
106
+ postMessage.mockClear();
107
+ mockLocalization.locale = 'tr';
108
+ mockLocalization.locales = SETTINGS_LOCALES;
109
+ mockLocalization.setLocale = jest.fn();
110
+ delete (window as { __iframeReadySent?: boolean }).__iframeReadySent;
111
+ });
112
+
113
+ describe('inside the editor', () => {
114
+ beforeEach(frameDocument);
115
+ afterAll(unframeDocument);
116
+
117
+ it('announces the languages once per page, however many placeholders mount', () => {
118
+ render(
119
+ <>
120
+ {placeholder('header')}
121
+ {placeholder('home-body')}
122
+ {placeholder('footer')}
123
+ </>
124
+ );
125
+
126
+ expect(localeFrames()).toHaveLength(1);
127
+ });
128
+
129
+ it("posts the brand's locales in the editor's shape", () => {
130
+ mockLocalization.locales = [
131
+ ...SETTINGS_LOCALES,
132
+ // No label in the settings: the dropdown shows the code, not a blank.
133
+ { value: 'de', apiValue: 'de-de' }
134
+ ];
135
+
136
+ render(placeholder('header'));
137
+
138
+ expect(localeFrames()).toEqual([
139
+ expect.objectContaining({
140
+ type: 'LOCALE_UPDATE',
141
+ data: {
142
+ locale: 'tr',
143
+ locales: [
144
+ { value: 'en', label: 'English' },
145
+ { value: 'tr', label: 'Türkçe' },
146
+ { value: 'de', label: 'de' }
147
+ ]
148
+ }
149
+ })
150
+ ]);
151
+ const [, targetOrigin] = postMessage.mock.calls.find(
152
+ ([message]) => message?.type === 'LOCALE_UPDATE'
153
+ );
154
+ expect(targetOrigin).toBe(EDITOR_ORIGIN);
155
+ });
156
+
157
+ it("follows the editor's switch exactly once, with several placeholders mounted", () => {
158
+ render(
159
+ <>
160
+ {placeholder('header')}
161
+ {placeholder('home-body')}
162
+ {placeholder('footer')}
163
+ </>
164
+ );
165
+
166
+ editorSwitch('en');
167
+
168
+ expect(mockLocalization.setLocale).toHaveBeenCalledTimes(1);
169
+ expect(mockLocalization.setLocale).toHaveBeenCalledWith('en');
170
+ });
171
+
172
+ it('does not echo the switch back to the editor', () => {
173
+ const view = render(<EditorLocaleBridge />);
174
+ postMessage.mockClear();
175
+
176
+ editorSwitch('en');
177
+
178
+ // The locale reaches the page (a same-document switch; the cores reload,
179
+ // which starts a fresh document and a fresh announcement anyway).
180
+ mockLocalization.locale = 'en';
181
+ view.rerender(<EditorLocaleBridge />);
182
+
183
+ expect(localeFrames()).toHaveLength(0);
184
+ });
185
+
186
+ it('tells the editor when the storefront changes language on its own', () => {
187
+ const view = render(<EditorLocaleBridge />);
188
+ postMessage.mockClear();
189
+
190
+ mockLocalization.locale = 'en';
191
+ view.rerender(<EditorLocaleBridge />);
192
+
193
+ expect(localeFrames()).toEqual([
194
+ expect.objectContaining({
195
+ data: expect.objectContaining({ locale: 'en' })
196
+ })
197
+ ]);
198
+ });
199
+
200
+ it('ignores a switch from an untrusted origin', () => {
201
+ render(<EditorLocaleBridge />);
202
+
203
+ editorSwitch('en', 'https://evil.example');
204
+
205
+ expect(mockLocalization.setLocale).not.toHaveBeenCalled();
206
+ });
207
+
208
+ it('ignores the current locale and one the storefront does not have', () => {
209
+ render(<EditorLocaleBridge />);
210
+
211
+ editorSwitch('tr');
212
+ editorSwitch('fr');
213
+
214
+ expect(mockLocalization.setLocale).not.toHaveBeenCalled();
215
+ });
216
+
217
+ it('keeps listening while any placeholder is left, and stops with the last', () => {
218
+ const body = render(<EditorLocaleBridge />);
219
+ const header = render(<EditorLocaleBridge />);
220
+
221
+ // A client-side navigation unmounts the page body; the layout's header
222
+ // slot stays, and has to keep answering the editor.
223
+ body.unmount();
224
+ editorSwitch('en');
225
+ expect(mockLocalization.setLocale).toHaveBeenCalledTimes(1);
226
+
227
+ header.unmount();
228
+ editorSwitch('en');
229
+ expect(mockLocalization.setLocale).toHaveBeenCalledTimes(1);
230
+ });
231
+ });
232
+
233
+ describe('outside the editor', () => {
234
+ beforeEach(unframeDocument);
235
+
236
+ it('posts nothing and follows nobody', () => {
237
+ const parentPost = jest.spyOn(window, 'postMessage');
238
+
239
+ render(
240
+ <>
241
+ {placeholder('header')}
242
+ {placeholder('footer')}
243
+ </>
244
+ );
245
+ editorSwitch('en');
246
+
247
+ expect(parentPost).not.toHaveBeenCalled();
248
+ expect(postMessage).not.toHaveBeenCalled();
249
+ expect(mockLocalization.setLocale).not.toHaveBeenCalled();
250
+
251
+ parentPost.mockRestore();
252
+ });
253
+
254
+ it('does not even listen', () => {
255
+ // Bare bridges, not wrappers: the wrapper keeps a message listener of its
256
+ // own, and this is about the bridge adding nothing to a live storefront.
257
+ const addListener = jest.spyOn(window, 'addEventListener');
258
+
259
+ render(
260
+ <>
261
+ <EditorLocaleBridge />
262
+ <EditorLocaleBridge />
263
+ </>
264
+ );
265
+
266
+ expect(
267
+ addListener.mock.calls.filter(([type]) => type === 'message')
268
+ ).toHaveLength(0);
269
+
270
+ addListener.mockRestore();
271
+ });
272
+ });
@@ -0,0 +1,165 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { useLocalization } from '@akinon/next/hooks';
5
+
6
+ import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
7
+
8
+ /**
9
+ * The storefront half of the editor's Language dropdown.
10
+ *
11
+ * The editor starts that dropdown with English only and fills it from the
12
+ * `LOCALE_UPDATE` frame the preview posts (`{ locale, locales }`); when the
13
+ * merchant picks another language it posts `LOCALE_UPDATE` back and expects the
14
+ * preview to follow. For a long time the only code answering either direction
15
+ * was the app-template's `LanguageSelect` — brand code. A brand whose header no
16
+ * longer renders that component (a header composed through ChromeSlot, a
17
+ * custom header section, a project older than the template) left the editor
18
+ * stuck on English with nothing to switch.
19
+ *
20
+ * So the bridge lives here, next to the other editor plumbing, and every
21
+ * placeholder mounts it. A page carries several placeholders (header and
22
+ * footer slots, the page body, every theme modal), and they come and go
23
+ * independently on client-side navigation, so the page-wide parts are kept at
24
+ * module level instead of per mount:
25
+ *
26
+ * - ONE `message` listener, attached by the first mounted bridge and removed
27
+ * with the last. A "first mount owns it" flag would go quiet as soon as that
28
+ * mount left (the page body unmounts on every navigation, the layout's header
29
+ * slot stays), so the listener is reference counted instead.
30
+ * - ONE announcement per state. Every mount reports the same localization
31
+ * (they all read one provider), and an identical frame is only posted once,
32
+ * however many placeholders mount at the same moment.
33
+ *
34
+ * Outside the editor it does nothing observable: nothing is attached or posted
35
+ * unless the storefront is framed, and `postToEditor` / `isTrustedEditorEvent`
36
+ * keep the traffic to trusted editor origins.
37
+ *
38
+ * Brands built from the app-template may still carry their own copy of this
39
+ * bridge. Their frames repeat ours, which the editor absorbs. Their listener
40
+ * also calls `setLocale` for the same switch, in the same event dispatch; on
41
+ * both the v1 and v2 cores `setLocale` assigns `location.href` from the
42
+ * unchanged current location, so the two calls target the same URL and the
43
+ * later assignment replaces the pending one before anything commits: one page
44
+ * load, one history entry. The template's own header already mounts its
45
+ * `LanguageSelect` twice (band and mobile menu), so this is how every template
46
+ * brand has always switched. Nothing needs to be deduplicated across the two.
47
+ */
48
+
49
+ export interface EditorLocaleOption {
50
+ value: string;
51
+ label: string;
52
+ }
53
+
54
+ interface LocaleState {
55
+ locale: string;
56
+ locales: EditorLocaleOption[];
57
+ setLocale: (locale: string) => void;
58
+ }
59
+
60
+ let mountedBridges = 0;
61
+ // The localization the page is showing, as last reported by any mounted bridge.
62
+ // Every mount reads the same provider, so whichever wrote last is right.
63
+ let current: LocaleState | null = null;
64
+ // The state the editor already knows about, so it is never told twice.
65
+ let lastAnnounced: string | null = null;
66
+
67
+ const isFramed = () =>
68
+ typeof window !== 'undefined' && window.parent !== window;
69
+
70
+ /**
71
+ * The editor's dropdown shape. Settings locales carry more (`apiValue`,
72
+ * `rtl`), and none of it is the editor's business. A locale without a label
73
+ * falls back to its code rather than showing up as an empty option.
74
+ */
75
+ const toEditorLocales = (locales: unknown): EditorLocaleOption[] =>
76
+ (Array.isArray(locales) ? locales : [])
77
+ .filter(
78
+ (entry): entry is { value: string; label?: unknown } =>
79
+ !!entry && typeof entry.value === 'string' && entry.value !== ''
80
+ )
81
+ .map((entry) => ({
82
+ value: entry.value,
83
+ label:
84
+ typeof entry.label === 'string' && entry.label
85
+ ? entry.label
86
+ : entry.value
87
+ }));
88
+
89
+ const announcementKey = (locale: string, locales: EditorLocaleOption[]) =>
90
+ JSON.stringify([locale, locales]);
91
+
92
+ const announce = ({ locale, locales }: LocaleState) => {
93
+ const key = announcementKey(locale, locales);
94
+ if (key === lastAnnounced) return;
95
+ lastAnnounced = key;
96
+
97
+ postToEditor({ type: 'LOCALE_UPDATE', data: { locale, locales } });
98
+ };
99
+
100
+ const handleEditorMessage = (event: MessageEvent) => {
101
+ if (!isTrustedEditorEvent(event)) return;
102
+ if (event.data?.type !== 'LOCALE_UPDATE') return;
103
+
104
+ const requested = event.data?.data?.locale;
105
+ if (!current || typeof requested !== 'string' || !requested) return;
106
+ if (requested === current.locale) return;
107
+ // A stale editor list (it starts with a hard-coded English) must not send
108
+ // the storefront to a locale it does not have.
109
+ if (
110
+ current.locales.length > 0 &&
111
+ !current.locales.some((option) => option.value === requested)
112
+ ) {
113
+ return;
114
+ }
115
+
116
+ // The editor is already showing `requested`. Recording it as announced keeps
117
+ // the switch from being echoed back when the new locale reaches the page.
118
+ lastAnnounced = announcementKey(requested, current.locales);
119
+ current.setLocale(requested);
120
+ };
121
+
122
+ /**
123
+ * Headless: reports the storefront's languages to the editor and follows the
124
+ * editor's language switch. Rendered by every ThemePlaceholderWrapper; see the
125
+ * module comment for why several of them still add up to one bridge.
126
+ */
127
+ export function EditorLocaleBridge(): null {
128
+ const { locale, locales, setLocale } = useLocalization();
129
+
130
+ useEffect(() => {
131
+ if (!isFramed()) return;
132
+
133
+ mountedBridges += 1;
134
+ if (mountedBridges === 1) {
135
+ window.addEventListener('message', handleEditorMessage);
136
+ }
137
+
138
+ return () => {
139
+ mountedBridges -= 1;
140
+ if (mountedBridges === 0) {
141
+ window.removeEventListener('message', handleEditorMessage);
142
+ current = null;
143
+ }
144
+ };
145
+ }, []);
146
+
147
+ useEffect(() => {
148
+ if (!isFramed() || !locale) return;
149
+
150
+ current = { locale, locales: toEditorLocales(locales), setLocale };
151
+ announce(current);
152
+ }, [locale, locales, setLocale]);
153
+
154
+ return null;
155
+ }
156
+
157
+ /** Test-only: reset module state between cases. */
158
+ export function __resetEditorLocaleBridgeForTests(): void {
159
+ if (typeof window !== 'undefined') {
160
+ window.removeEventListener('message', handleEditorMessage);
161
+ }
162
+ mountedBridges = 0;
163
+ current = null;
164
+ lastAnnounced = null;
165
+ }
@@ -30,6 +30,7 @@ import { isPublishWindowVisible } from './utils/publish-window';
30
30
  import { buildClientRequestUrl } from '@akinon/next/utils';
31
31
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
32
32
  import { parseEditorMessage, postToEditor } from './editor-bridge';
33
+ import { EditorLocaleBridge } from './editor-locale-bridge';
33
34
  import { useBrowseMode } from './browse-mode';
34
35
  import { neutralizeStyleTagBreakout } from './utils/css-safety';
35
36
  import { buildCustomFontFaceCss } from './utils/custom-fonts';
@@ -983,6 +984,12 @@ export default function ThemePlaceholderWrapper({
983
984
  state when the designer comes back, so the panel and the canvas agree.
984
985
  */}
985
986
  <PartStatePreview isDesigner={isEditing} />
987
+ {/*
988
+ Tells the editor which languages this storefront has and follows its
989
+ language switch. Every placeholder renders one; they share a single
990
+ listener and a single announcement (see editor-locale-bridge.tsx).
991
+ */}
992
+ <EditorLocaleBridge />
986
993
  {themeBackgroundCSS && (
987
994
  <style dangerouslySetInnerHTML={{ __html: themeBackgroundCSS }} />
988
995
  )}