@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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.41-beta.0
3
+ ## 2.0.41
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd75]
8
- - @akinon/next@2.0.41-beta.0
9
- - @akinon/theme-contract@2.0.41-beta.0
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-beta.0",
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-beta.0",
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-beta.0",
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-beta.0",
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(false);
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
- return <div data-chrome-fallback={slug}>{children}</div>;
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}>{fallback}</ChromeFallback>
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
+ }
@@ -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
- [/^\/account\/address/, PLACEHOLDER_SLUGS.accountAddresses],
29
- [/^\/account\/profile/, PLACEHOLDER_SLUGS.accountProfile],
30
- [/^\/account\/change-password/, PLACEHOLDER_SLUGS.accountChangePassword],
31
- [/^\/account\/change-email/, PLACEHOLDER_SLUGS.accountChangeEmail],
32
- [/^\/account\/coupons/, PLACEHOLDER_SLUGS.accountCoupons],
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\/faq/, PLACEHOLDER_SLUGS.accountFaq],
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
 
@@ -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 (referrerOrigin && guard.isTrusted(referrerOrigin)) {
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,