@akinon/pz-theme 2.0.41-beta.0 → 2.0.41
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/__tests__/account-slot-coverage.test.ts +86 -0
- package/src/__tests__/chrome-takeover-gate.test.tsx +46 -0
- package/src/__tests__/editor-bridge-target.test.ts +104 -0
- package/src/__tests__/route-slug.test.ts +47 -0
- package/src/chrome/chrome-fallback.tsx +25 -3
- package/src/chrome/chrome-slot.tsx +33 -4
- package/src/chrome/placeholder-slugs.ts +2 -0
- package/src/chrome/preview-auth-state.tsx +50 -0
- package/src/chrome/route-slug.ts +21 -10
- package/src/editor-bridge.ts +20 -2
- package/src/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.41
|
|
3
|
+
## 2.0.41
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- @akinon/next@2.0.41
|
|
9
|
-
- @akinon/theme-contract@2.0.41
|
|
7
|
+
- 02840711: ZERO-4725: Make every account subpage individually takeover-able — new account-dashboard-body and account-contact-body slugs, a ChromeSlot per account subpage plus header and footer slots in the reference app (route groups keep order cancellation out of the detail takeover), an account-menu slot with the project menu as fallback, a PREVIEW_AUTH_STATE report so the editor page picker can offer account pages once the preview signs in, embed-aware SameSite on auth and commerce session cookies, and an editor bridge that can no longer be poisoned into posting at the preview's own origin
|
|
8
|
+
- @akinon/next@2.0.41
|
|
9
|
+
- @akinon/theme-contract@2.0.41
|
|
10
10
|
|
|
11
11
|
## 2.0.40
|
|
12
12
|
|
package/package.json
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
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.41
|
|
4
|
+
"version": "2.0.41",
|
|
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.41
|
|
11
|
+
"@akinon/next": "2.0.41",
|
|
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.41
|
|
16
|
+
"@akinon/theme-contract": "2.0.41",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"tailwind-merge": "^2.5.4",
|
|
20
20
|
"zod": "4.4.1"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@akinon/next": "2.0.41
|
|
23
|
+
"@akinon/next": "2.0.41",
|
|
24
24
|
"@testing-library/jest-dom": "5.16.4",
|
|
25
25
|
"@testing-library/react": "16.3.2",
|
|
26
26
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
import { PLACEHOLDER_SLUGS } from '../chrome/placeholder-slugs';
|
|
5
|
+
import { isPageMountedSlug } from '../chrome/route-slug';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PAGE_MOUNTED_SLUGS is a promise: "a page or layout mounts this slug's slot
|
|
9
|
+
* itself, so a route-aware catch-all must skip it". For a while the account
|
|
10
|
+
* half of that promise was false — most account slugs were declared
|
|
11
|
+
* page-mounted while nothing mounted them, so the editor offered pages the
|
|
12
|
+
* storefront never read. This suite pins each slug to the exact layout that
|
|
13
|
+
* mounts it in the reference app (a token anywhere in the tree would also
|
|
14
|
+
* match comments or a mount under the wrong route).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const ACCOUNT_APP_DIR = path.resolve(
|
|
18
|
+
__dirname,
|
|
19
|
+
'../../../..',
|
|
20
|
+
'apps/projectzeronext/src/app/[pz]/account'
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
/** Placeholder key -> the file (relative to the account dir) mounting it. */
|
|
24
|
+
const EXPECTED_MOUNTS: Record<string, string> = {
|
|
25
|
+
accountShell: 'layout.tsx',
|
|
26
|
+
accountMenu: 'layout.tsx',
|
|
27
|
+
accountDashboard: '(dashboard)/layout.tsx',
|
|
28
|
+
accountOrders: 'orders/(list)/layout.tsx',
|
|
29
|
+
accountOrderDetail: 'orders/[id]/(detail)/layout.tsx',
|
|
30
|
+
accountAddresses: 'address/layout.tsx',
|
|
31
|
+
accountProfile: 'profile/layout.tsx',
|
|
32
|
+
accountChangePassword: 'change-password/layout.tsx',
|
|
33
|
+
accountChangeEmail: 'change-email/layout.tsx',
|
|
34
|
+
accountCoupons: 'coupons/layout.tsx',
|
|
35
|
+
accountFavourites: 'favourite-products/layout.tsx',
|
|
36
|
+
accountFaq: 'faq/layout.tsx',
|
|
37
|
+
accountContact: 'contact/layout.tsx'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// No scaffold page exists for these routes yet, so there is nothing to
|
|
41
|
+
// mount. The dir assertion below fails the moment someone adds the route —
|
|
42
|
+
// that is the cue to mount the slot and shrink this list.
|
|
43
|
+
const UNMOUNTED_ACCOUNT_KEYS: Record<string, string> = {
|
|
44
|
+
accountNotifications: 'notification-settings',
|
|
45
|
+
accountStockAlerts: 'stock-waiting-products'
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const getMountableAccountKeys = () =>
|
|
49
|
+
Object.keys(PLACEHOLDER_SLUGS).filter(
|
|
50
|
+
(key) =>
|
|
51
|
+
key.startsWith('account') &&
|
|
52
|
+
isPageMountedSlug(
|
|
53
|
+
PLACEHOLDER_SLUGS[key as keyof typeof PLACEHOLDER_SLUGS]
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const maybeDescribe = fs.existsSync(ACCOUNT_APP_DIR)
|
|
58
|
+
? describe
|
|
59
|
+
: describe.skip;
|
|
60
|
+
|
|
61
|
+
maybeDescribe('account slot coverage in the reference app', () => {
|
|
62
|
+
it('mounts every page-mounted account slug in its own layout', () => {
|
|
63
|
+
Object.entries(EXPECTED_MOUNTS).forEach(([key, relativePath]) => {
|
|
64
|
+
const file = path.join(ACCOUNT_APP_DIR, relativePath);
|
|
65
|
+
expect(fs.existsSync(file)).toBe(true);
|
|
66
|
+
expect(fs.readFileSync(file, 'utf8')).toMatch(
|
|
67
|
+
new RegExp(`PLACEHOLDER_SLUGS\\.${key}\\b`)
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('accounts for every mountable account key exactly once', () => {
|
|
73
|
+
const covered = [
|
|
74
|
+
...Object.keys(EXPECTED_MOUNTS),
|
|
75
|
+
...Object.keys(UNMOUNTED_ACCOUNT_KEYS)
|
|
76
|
+
].sort();
|
|
77
|
+
|
|
78
|
+
expect(covered).toEqual(getMountableAccountKeys().sort());
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps the unmounted list honest: those routes have no scaffold page', () => {
|
|
82
|
+
Object.values(UNMOUNTED_ACCOUNT_KEYS).forEach((dirName) => {
|
|
83
|
+
expect(fs.existsSync(path.join(ACCOUNT_APP_DIR, dirName))).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -249,4 +249,50 @@ describe('ChromeFallback takeover gate', () => {
|
|
|
249
249
|
pushTheme([]);
|
|
250
250
|
expect(screen.getByTestId('project-header')).toBeInTheDocument();
|
|
251
251
|
});
|
|
252
|
+
|
|
253
|
+
it('starts hidden when the server rendered a composition and returns on removal', () => {
|
|
254
|
+
// The composed ChromeSlot branch ships the fallback too, hidden. Without
|
|
255
|
+
// it, deleting a menu or header composition blanked the area until a
|
|
256
|
+
// full reload — there was nothing left in the tree to come back.
|
|
257
|
+
render(
|
|
258
|
+
<ChromeFallback slug="header" initiallyOverridden>
|
|
259
|
+
<div data-testid="project-header">project header</div>
|
|
260
|
+
</ChromeFallback>
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
expect(screen.queryByTestId('project-header')).not.toBeInTheDocument();
|
|
264
|
+
|
|
265
|
+
pushTheme([]);
|
|
266
|
+
expect(screen.getByTestId('project-header')).toBeInTheDocument();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('ChromeFallback on the live site', () => {
|
|
271
|
+
beforeAll(() => {
|
|
272
|
+
// Not framed: the live site is its own top window.
|
|
273
|
+
Object.defineProperty(window, 'top', {
|
|
274
|
+
configurable: true,
|
|
275
|
+
value: window
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('never renders the fallback under a server-rendered composition', () => {
|
|
280
|
+
const { container } = render(
|
|
281
|
+
<ChromeFallback slug="header" initiallyOverridden>
|
|
282
|
+
<div data-testid="project-header">project header</div>
|
|
283
|
+
</ChromeFallback>
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
expect(container).toBeEmptyDOMElement();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('renders the brand markup when there is no composition', () => {
|
|
290
|
+
render(
|
|
291
|
+
<ChromeFallback slug="header">
|
|
292
|
+
<div data-testid="live-header">project header</div>
|
|
293
|
+
</ChromeFallback>
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
expect(screen.getByTestId('live-header')).toBeInTheDocument();
|
|
297
|
+
});
|
|
252
298
|
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an outbound message goes when the editor has not spoken yet.
|
|
3
|
+
*
|
|
4
|
+
* The bridge falls back to `document.referrer` to find the embedding editor
|
|
5
|
+
* — but after an in-iframe navigation (a login redirect, a link click) the
|
|
6
|
+
* referrer is the PREVIOUS STOREFRONT PAGE. Targeting it posts the message
|
|
7
|
+
* at the iframe's own origin, the parent's origin check drops it silently,
|
|
8
|
+
* and one-shot messages such as PREVIEW_AUTH_STATE never arrive: the editor
|
|
9
|
+
* keeps hiding the account pages from a signed-in preview.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const EDITOR_A = 'http://editor-a.example';
|
|
13
|
+
const EDITOR_B = 'http://editor-b.example';
|
|
14
|
+
|
|
15
|
+
type Bridge = typeof import('../editor-bridge');
|
|
16
|
+
|
|
17
|
+
const loadBridge = (): Bridge => {
|
|
18
|
+
let bridge: Bridge;
|
|
19
|
+
jest.isolateModules(() => {
|
|
20
|
+
bridge = require('../editor-bridge');
|
|
21
|
+
});
|
|
22
|
+
return bridge!;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const setReferrer = (value: string) => {
|
|
26
|
+
Object.defineProperty(document, 'referrer', {
|
|
27
|
+
configurable: true,
|
|
28
|
+
value
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe('postToEditor target resolution', () => {
|
|
33
|
+
const postMessage = jest.fn();
|
|
34
|
+
|
|
35
|
+
beforeAll(() => {
|
|
36
|
+
Object.defineProperty(window, 'top', {
|
|
37
|
+
configurable: true,
|
|
38
|
+
value: { name: 'designer' }
|
|
39
|
+
});
|
|
40
|
+
Object.defineProperty(window, 'parent', {
|
|
41
|
+
configurable: true,
|
|
42
|
+
value: { postMessage }
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
postMessage.mockClear();
|
|
48
|
+
process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS = `${EDITOR_A},${EDITOR_B}`;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
delete process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('uses the referrer when it is a trusted embedder', () => {
|
|
56
|
+
setReferrer(`${EDITOR_B}/builder`);
|
|
57
|
+
|
|
58
|
+
loadBridge().postToEditor({
|
|
59
|
+
type: 'PREVIEW_AUTH_STATE',
|
|
60
|
+
data: { isAuthenticated: true }
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(postMessage).toHaveBeenCalledTimes(1);
|
|
64
|
+
expect(postMessage.mock.calls[0][1]).toBe(EDITOR_B);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does not let same-window traffic poison the editor origin', () => {
|
|
68
|
+
setReferrer('');
|
|
69
|
+
const bridge = loadBridge();
|
|
70
|
+
|
|
71
|
+
// Next's dev overlay and react-refresh post to their own window; the
|
|
72
|
+
// event carries this window's origin, which local-dev trust would accept.
|
|
73
|
+
const selfEvent = {
|
|
74
|
+
origin: window.location.origin,
|
|
75
|
+
source: window,
|
|
76
|
+
data: { type: 'UPDATE_THEME' }
|
|
77
|
+
} as unknown as MessageEvent;
|
|
78
|
+
expect(bridge.isTrustedEditorEvent(selfEvent)).toBe(false);
|
|
79
|
+
|
|
80
|
+
bridge.postToEditor({
|
|
81
|
+
type: 'PREVIEW_AUTH_STATE',
|
|
82
|
+
data: { isAuthenticated: true }
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const targets = postMessage.mock.calls.map((call) => call[1]);
|
|
86
|
+
expect(targets).not.toContain(window.location.origin);
|
|
87
|
+
expect(targets.sort()).toEqual([EDITOR_A, EDITOR_B].sort());
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('never targets the window own origin after an in-iframe navigation', () => {
|
|
91
|
+
// Post-login referrer: the storefront itself.
|
|
92
|
+
setReferrer(`${window.location.origin}/users/auth`);
|
|
93
|
+
|
|
94
|
+
loadBridge().postToEditor({
|
|
95
|
+
type: 'PREVIEW_AUTH_STATE',
|
|
96
|
+
data: { isAuthenticated: true }
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const targets = postMessage.mock.calls.map((call) => call[1]);
|
|
100
|
+
expect(targets).not.toContain(window.location.origin);
|
|
101
|
+
// Falls through to the allowlist instead of dropping the message.
|
|
102
|
+
expect(targets.sort()).toEqual([EDITOR_A, EDITOR_B].sort());
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -23,6 +23,8 @@ describe('resolveBodySlug', () => {
|
|
|
23
23
|
'/account/favourite-products/',
|
|
24
24
|
'/account/stock-waiting-products/',
|
|
25
25
|
'/account/notification-settings/',
|
|
26
|
+
'/account/faq',
|
|
27
|
+
'/account/contact',
|
|
26
28
|
'/users/orders',
|
|
27
29
|
'/users/orders/12345'
|
|
28
30
|
];
|
|
@@ -36,6 +38,51 @@ describe('resolveBodySlug', () => {
|
|
|
36
38
|
expect(seen.size).toBe(accountPaths.length);
|
|
37
39
|
});
|
|
38
40
|
|
|
41
|
+
it('gives the account root its own body and keeps the shell for the rest', () => {
|
|
42
|
+
expect(resolveBodySlug('/account')).toBe(
|
|
43
|
+
PLACEHOLDER_SLUGS.accountDashboard
|
|
44
|
+
);
|
|
45
|
+
expect(resolveBodySlug('/account/')).toBe(
|
|
46
|
+
PLACEHOLDER_SLUGS.accountDashboard
|
|
47
|
+
);
|
|
48
|
+
expect(resolveBodySlug('/account/contact')).toBe(
|
|
49
|
+
PLACEHOLDER_SLUGS.accountContact
|
|
50
|
+
);
|
|
51
|
+
// Screens with no dedicated slug compose the layout-level shell band.
|
|
52
|
+
expect(resolveBodySlug('/account/my-quotations')).toBe(
|
|
53
|
+
PLACEHOLDER_SLUGS.accountShell
|
|
54
|
+
);
|
|
55
|
+
// A continued segment is a different screen — it must not compose its
|
|
56
|
+
// neighbour's placeholder.
|
|
57
|
+
expect(resolveBodySlug('/account/contact-us')).toBe(
|
|
58
|
+
PLACEHOLDER_SLUGS.accountShell
|
|
59
|
+
);
|
|
60
|
+
expect(resolveBodySlug('/account/address-book')).toBe(
|
|
61
|
+
PLACEHOLDER_SLUGS.accountShell
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('accepts the favourites spellings brands actually ship', () => {
|
|
66
|
+
[
|
|
67
|
+
'/account/favourite-products',
|
|
68
|
+
'/account/favorite-products',
|
|
69
|
+
'/account/favourites',
|
|
70
|
+
'/account/favorites',
|
|
71
|
+
'/account/wishlist'
|
|
72
|
+
].forEach((path) => {
|
|
73
|
+
expect(resolveBodySlug(path)).toBe(PLACEHOLDER_SLUGS.accountFavourites);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('keeps deep account paths on their section slug', () => {
|
|
78
|
+
expect(resolveBodySlug('/account/address/5')).toBe(
|
|
79
|
+
PLACEHOLDER_SLUGS.accountAddresses
|
|
80
|
+
);
|
|
81
|
+
expect(resolveBodySlug('/users/orders/5/cancellation')).toBe(
|
|
82
|
+
PLACEHOLDER_SLUGS.accountOrderDetail
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
39
86
|
it('matches the deeper route first', () => {
|
|
40
87
|
expect(resolveBodySlug('/users/orders')).toBe(
|
|
41
88
|
PLACEHOLDER_SLUGS.accountOrders
|
|
@@ -16,14 +16,28 @@ import { isTrustedEditorEvent, postToEditor } from '../editor-bridge';
|
|
|
16
16
|
*/
|
|
17
17
|
export function ChromeFallback({
|
|
18
18
|
slug,
|
|
19
|
-
children
|
|
19
|
+
children,
|
|
20
|
+
wrapperClassName,
|
|
21
|
+
initiallyOverridden = false
|
|
20
22
|
}: {
|
|
21
23
|
slug: string;
|
|
22
24
|
children: React.ReactNode;
|
|
25
|
+
/**
|
|
26
|
+
* Applied to the live-site wrapper only. The designer overlay below needs
|
|
27
|
+
* a real positioned box for its scrim, so it deliberately ignores this.
|
|
28
|
+
*/
|
|
29
|
+
wrapperClassName?: string;
|
|
30
|
+
/**
|
|
31
|
+
* True when the server rendered a composition for this slug. The brand
|
|
32
|
+
* markup then stays out of the flow — on the live site permanently, in
|
|
33
|
+
* the designer until the composition's last section is removed and the
|
|
34
|
+
* project markup has to come back.
|
|
35
|
+
*/
|
|
36
|
+
initiallyOverridden?: boolean;
|
|
23
37
|
}) {
|
|
24
38
|
const [inDesigner, setInDesigner] = useState(false);
|
|
25
39
|
const [isHovered, setIsHovered] = useState(false);
|
|
26
|
-
const [isOverridden, setIsOverridden] = useState(
|
|
40
|
+
const [isOverridden, setIsOverridden] = useState(initiallyOverridden);
|
|
27
41
|
|
|
28
42
|
useEffect(() => {
|
|
29
43
|
if (typeof window !== 'undefined' && window.self !== window.top) {
|
|
@@ -76,7 +90,15 @@ export function ChromeFallback({
|
|
|
76
90
|
}, [slug]);
|
|
77
91
|
|
|
78
92
|
if (!inDesigner) {
|
|
79
|
-
|
|
93
|
+
// Composed on the live site: the composition owns the area, the brand
|
|
94
|
+
// markup must never double-render under it.
|
|
95
|
+
if (initiallyOverridden) return null;
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div data-chrome-fallback={slug} className={wrapperClassName}>
|
|
99
|
+
{children}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
80
102
|
}
|
|
81
103
|
|
|
82
104
|
if (isOverridden) return null;
|
|
@@ -18,6 +18,18 @@ interface ChromeSlotProps {
|
|
|
18
18
|
* what the blocks expect.
|
|
19
19
|
*/
|
|
20
20
|
pageContext?: Record<string, unknown> | null;
|
|
21
|
+
/**
|
|
22
|
+
* Class for the slot's wrapper element. Pass `contents` when the slot
|
|
23
|
+
* replaces a direct flex/grid child, so the wrapper does not become a box
|
|
24
|
+
* of its own and break the parent layout.
|
|
25
|
+
*/
|
|
26
|
+
className?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Class for the fallback wrapper on the live site. Pair with
|
|
29
|
+
* `className="contents"` so the brand markup keeps owning its own layout
|
|
30
|
+
* classes; the designer overlay keeps a real box either way.
|
|
31
|
+
*/
|
|
32
|
+
fallbackClassName?: string;
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
/**
|
|
@@ -29,7 +41,9 @@ interface ChromeSlotProps {
|
|
|
29
41
|
export default async function ChromeSlot({
|
|
30
42
|
slug,
|
|
31
43
|
fallback,
|
|
32
|
-
pageContext = null
|
|
44
|
+
pageContext = null,
|
|
45
|
+
className,
|
|
46
|
+
fallbackClassName
|
|
33
47
|
}: ChromeSlotProps) {
|
|
34
48
|
let hasComposition = false;
|
|
35
49
|
|
|
@@ -50,16 +64,31 @@ export default async function ChromeSlot({
|
|
|
50
64
|
|
|
51
65
|
if (hasComposition) {
|
|
52
66
|
return (
|
|
53
|
-
<div data-chrome-slot={slug}>
|
|
67
|
+
<div data-chrome-slot={slug} className={className}>
|
|
68
|
+
{/* The fallback ships hidden: on the live site it never renders, but
|
|
69
|
+
in the designer it must be able to COME BACK when the last
|
|
70
|
+
section is removed — without it in the tree, deleting a menu or
|
|
71
|
+
header composition would blank the area until a full reload. */}
|
|
72
|
+
{fallback ? (
|
|
73
|
+
<ChromeFallback
|
|
74
|
+
slug={slug}
|
|
75
|
+
wrapperClassName={fallbackClassName}
|
|
76
|
+
initiallyOverridden
|
|
77
|
+
>
|
|
78
|
+
{fallback}
|
|
79
|
+
</ChromeFallback>
|
|
80
|
+
) : null}
|
|
54
81
|
<ThemePlaceholder slug={slug} pageContext={pageContext} />
|
|
55
82
|
</div>
|
|
56
83
|
);
|
|
57
84
|
}
|
|
58
85
|
|
|
59
86
|
return (
|
|
60
|
-
<div data-chrome-slot={slug}>
|
|
87
|
+
<div data-chrome-slot={slug} className={className}>
|
|
61
88
|
{fallback ? (
|
|
62
|
-
<ChromeFallback slug={slug}
|
|
89
|
+
<ChromeFallback slug={slug} wrapperClassName={fallbackClassName}>
|
|
90
|
+
{fallback}
|
|
91
|
+
</ChromeFallback>
|
|
63
92
|
) : null}
|
|
64
93
|
{/* Empty until the designer overrides this area from the editor */}
|
|
65
94
|
<ThemePlaceholder slug={slug} pageContext={pageContext} />
|
|
@@ -26,6 +26,7 @@ export const PLACEHOLDER_SLUGS = {
|
|
|
26
26
|
// across orders, addresses, profile and the rest.
|
|
27
27
|
accountShell: 'account-page-body',
|
|
28
28
|
accountMenu: 'account-menu',
|
|
29
|
+
accountDashboard: 'account-dashboard-body',
|
|
29
30
|
accountOrders: 'account-orders-body',
|
|
30
31
|
accountOrderDetail: 'account-order-detail-body',
|
|
31
32
|
accountAddresses: 'account-address-body',
|
|
@@ -35,6 +36,7 @@ export const PLACEHOLDER_SLUGS = {
|
|
|
35
36
|
accountCoupons: 'account-coupons-body',
|
|
36
37
|
accountFavourites: 'account-favourites-body',
|
|
37
38
|
accountFaq: 'account-faq-content',
|
|
39
|
+
accountContact: 'account-contact-body',
|
|
38
40
|
accountNotifications: 'account-notifications-body',
|
|
39
41
|
accountStockAlerts: 'account-stock-alerts-body',
|
|
40
42
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { useSession } from 'next-auth/react';
|
|
5
|
+
|
|
6
|
+
import { isTrustedEditorEvent, postToEditor } from '../editor-bridge';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reports the preview session's auth state to the editor, which hides the
|
|
10
|
+
* account entries of its page picker until the preview signs in. Mount once
|
|
11
|
+
* from the root layout: `postToEditor` no-ops outside the editor iframe and
|
|
12
|
+
* only posts to trusted origins, so the reporter is inert on the live site.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately client-side. Reading the session on the server would bake
|
|
15
|
+
* `isAuthenticated: false` into force-static pages, and the editor would
|
|
16
|
+
* drop its account entries every time a cached page loads in the preview.
|
|
17
|
+
*/
|
|
18
|
+
export function PreviewAuthStateReporter() {
|
|
19
|
+
const { status } = useSession();
|
|
20
|
+
const statusRef = useRef(status);
|
|
21
|
+
statusRef.current = status;
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (status === 'loading') return;
|
|
25
|
+
postToEditor({
|
|
26
|
+
type: 'PREVIEW_AUTH_STATE',
|
|
27
|
+
data: { isAuthenticated: status === 'authenticated' }
|
|
28
|
+
});
|
|
29
|
+
}, [status]);
|
|
30
|
+
|
|
31
|
+
// The mount-time post can race the origin handshake (before the editor's
|
|
32
|
+
// first frame the bridge can only guess the target). Re-report once real
|
|
33
|
+
// editor traffic proves the origin — accepting the event also records it
|
|
34
|
+
// as the postMessage target, so this send cannot miss.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const handleMessage = (event: MessageEvent) => {
|
|
37
|
+
if (!isTrustedEditorEvent(event)) return;
|
|
38
|
+
if (statusRef.current === 'loading') return;
|
|
39
|
+
postToEditor({
|
|
40
|
+
type: 'PREVIEW_AUTH_STATE',
|
|
41
|
+
data: { isAuthenticated: statusRef.current === 'authenticated' }
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
window.addEventListener('message', handleMessage);
|
|
46
|
+
return () => window.removeEventListener('message', handleMessage);
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
return null;
|
|
50
|
+
}
|
package/src/chrome/route-slug.ts
CHANGED
|
@@ -19,30 +19,39 @@ export type RouteSlugRule = [RegExp, string];
|
|
|
19
19
|
* resolves to the order detail body and not to the order list.
|
|
20
20
|
*/
|
|
21
21
|
const DEFAULT_RULES: RouteSlugRule[] = [
|
|
22
|
-
// Account — deepest first
|
|
22
|
+
// Account — deepest first. The detail rule also matches deeper order
|
|
23
|
+
// screens (e.g. cancellation); the reference app only mounts the slot on
|
|
24
|
+
// the detail screen itself, so those stay code-owned.
|
|
23
25
|
[
|
|
24
26
|
/^\/(account|users)\/orders\/[^/]+/,
|
|
25
27
|
PLACEHOLDER_SLUGS.accountOrderDetail
|
|
26
28
|
],
|
|
27
29
|
[/^\/(account|users)\/orders\/?$/, PLACEHOLDER_SLUGS.accountOrders],
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
[/^\/account\/
|
|
32
|
-
[/^\/account\/
|
|
30
|
+
// Every section rule ends at a segment boundary, mirroring the editor's
|
|
31
|
+
// table: a continued segment (contact-us, address-book) is a DIFFERENT
|
|
32
|
+
// screen and must fall through to the shell, not compose its neighbour.
|
|
33
|
+
[/^\/account\/address(es)?(\/|$)/, PLACEHOLDER_SLUGS.accountAddresses],
|
|
34
|
+
[/^\/account\/profile(\/|$)/, PLACEHOLDER_SLUGS.accountProfile],
|
|
35
|
+
[/^\/account\/change-password(\/|$)/, PLACEHOLDER_SLUGS.accountChangePassword],
|
|
36
|
+
[/^\/account\/change-email(\/|$)/, PLACEHOLDER_SLUGS.accountChangeEmail],
|
|
37
|
+
[/^\/account\/coupons(\/|$)/, PLACEHOLDER_SLUGS.accountCoupons],
|
|
33
38
|
[
|
|
34
|
-
/^\/account\/(favourite-products|favorites|wishlist)/,
|
|
39
|
+
/^\/account\/(favourite-products|favorite-products|favourites|favorites|wishlist)(\/|$)/,
|
|
35
40
|
PLACEHOLDER_SLUGS.accountFavourites
|
|
36
41
|
],
|
|
37
42
|
[
|
|
38
|
-
/^\/account\/stock-(waiting-products|alerts)/,
|
|
43
|
+
/^\/account\/stock-(waiting-products|alerts)(\/|$)/,
|
|
39
44
|
PLACEHOLDER_SLUGS.accountStockAlerts
|
|
40
45
|
],
|
|
41
46
|
[
|
|
42
|
-
/^\/account\/notification-settings/,
|
|
47
|
+
/^\/account\/notification-settings(\/|$)/,
|
|
43
48
|
PLACEHOLDER_SLUGS.accountNotifications
|
|
44
49
|
],
|
|
45
|
-
[/^\/account\/
|
|
50
|
+
[/^\/account\/contact(\/|$)/, PLACEHOLDER_SLUGS.accountContact],
|
|
51
|
+
[/^\/account\/faq(\/|$)/, PLACEHOLDER_SLUGS.accountFaq],
|
|
52
|
+
// The account root screen owns its body; the shell slug below stays the
|
|
53
|
+
// layout-level band, so a dashboard composition cannot leak onto it.
|
|
54
|
+
[/^\/account\/?$/, PLACEHOLDER_SLUGS.accountDashboard],
|
|
46
55
|
// Any other account screen composes the shell body, which is what the
|
|
47
56
|
// account layout mounts for it.
|
|
48
57
|
[/^\/account(\/|$)/, PLACEHOLDER_SLUGS.accountShell],
|
|
@@ -92,6 +101,7 @@ const PAGE_MOUNTED_SLUGS = new Set<string>([
|
|
|
92
101
|
PLACEHOLDER_SLUGS.orderSuccess,
|
|
93
102
|
PLACEHOLDER_SLUGS.accountShell,
|
|
94
103
|
PLACEHOLDER_SLUGS.accountMenu,
|
|
104
|
+
PLACEHOLDER_SLUGS.accountDashboard,
|
|
95
105
|
PLACEHOLDER_SLUGS.accountOrders,
|
|
96
106
|
PLACEHOLDER_SLUGS.accountOrderDetail,
|
|
97
107
|
PLACEHOLDER_SLUGS.accountAddresses,
|
|
@@ -103,6 +113,7 @@ const PAGE_MOUNTED_SLUGS = new Set<string>([
|
|
|
103
113
|
PLACEHOLDER_SLUGS.accountStockAlerts,
|
|
104
114
|
PLACEHOLDER_SLUGS.accountNotifications,
|
|
105
115
|
PLACEHOLDER_SLUGS.accountFaq,
|
|
116
|
+
PLACEHOLDER_SLUGS.accountContact,
|
|
106
117
|
PLACEHOLDER_SLUGS.flatPage
|
|
107
118
|
]);
|
|
108
119
|
|
package/src/editor-bridge.ts
CHANGED
|
@@ -47,6 +47,17 @@ const warnOnce = (key: string, message: string) => {
|
|
|
47
47
|
* its origin as the postMessage target for subsequent `postToEditor` calls.
|
|
48
48
|
*/
|
|
49
49
|
export function isTrustedEditorEvent(event: MessageEvent): boolean {
|
|
50
|
+
// The editor is the embedding page, so its frames arrive from another
|
|
51
|
+
// window. Same-window postMessage traffic (Next's dev overlay, react
|
|
52
|
+
// refresh, app code) carries this window's own origin — which the local
|
|
53
|
+
// dev allowance would happily trust — and accepting it would poison
|
|
54
|
+
// `activeEditorOrigin` with ourselves: every later message then targets
|
|
55
|
+
// the preview's own origin and silently misses the real editor.
|
|
56
|
+
if (typeof window !== 'undefined') {
|
|
57
|
+
if (event.source === window) return false;
|
|
58
|
+
if (event.origin === window.location.origin) return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
if (guard.isTrusted(event.origin)) {
|
|
51
62
|
activeEditorOrigin = event.origin;
|
|
52
63
|
return true;
|
|
@@ -102,9 +113,16 @@ const resolveTargetOrigins = (): string[] => {
|
|
|
102
113
|
|
|
103
114
|
// Before the editor's first message (e.g. IFRAME_READY on mount), the
|
|
104
115
|
// embedding page's origin is only known via the referrer — trust it only
|
|
105
|
-
// if the guard does.
|
|
116
|
+
// if the guard does. After an in-iframe navigation (login redirect, a
|
|
117
|
+
// link click) the referrer is the PREVIOUS STOREFRONT PAGE, not the
|
|
118
|
+
// editor — targeting it would silently drop the message on the parent's
|
|
119
|
+
// origin check, so the window's own origin never qualifies.
|
|
106
120
|
const referrerOrigin = safeOrigin(document.referrer);
|
|
107
|
-
if (
|
|
121
|
+
if (
|
|
122
|
+
referrerOrigin &&
|
|
123
|
+
referrerOrigin !== window.location.origin &&
|
|
124
|
+
guard.isTrusted(referrerOrigin)
|
|
125
|
+
) {
|
|
108
126
|
return [referrerOrigin];
|
|
109
127
|
}
|
|
110
128
|
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ export type { Block } from './theme-block';
|
|
|
22
22
|
|
|
23
23
|
export { default as ChromeSlot } from './chrome/chrome-slot';
|
|
24
24
|
export { ChromeFallback } from './chrome/chrome-fallback';
|
|
25
|
+
export { PreviewAuthStateReporter } from './chrome/preview-auth-state';
|
|
25
26
|
export { LiveBasketPageContext } from './chrome/live-basket-page-context';
|
|
26
27
|
export {
|
|
27
28
|
LiveOrderSuccessPageContext,
|