@akinon/pz-theme 2.0.43-rc.0 → 2.0.43
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 +7 -31
- package/package.json +4 -4
- package/src/__tests__/checkout-pieces.test.tsx +420 -0
- package/src/__tests__/chrome-empty-zone.test.tsx +3 -3
- package/src/__tests__/code-owned-region.test.tsx +30 -6
- package/src/__tests__/composable-region.test.tsx +481 -0
- package/src/__tests__/part-styles.test.ts +24 -4
- package/src/__tests__/registry-parity.test.ts +3 -0
- package/src/__tests__/route-slug.test.ts +11 -0
- package/src/blocks/__tests__/checkout-outlet-blocks.test.tsx +623 -0
- package/src/blocks/block-renderer-registry.tsx +6 -0
- package/src/blocks/checkout-flow-outlet-block.tsx +104 -0
- package/src/blocks/checkout-outlet-base.tsx +241 -0
- package/src/blocks/checkout-steps-outlet-block.tsx +57 -0
- package/src/blocks/checkout-summary-outlet-block.tsx +43 -0
- package/src/chrome/checkout-piece.tsx +145 -0
- package/src/chrome/checkout-pieces-provider.tsx +28 -0
- package/src/chrome/checkout-pieces.ts +173 -0
- package/src/chrome/chrome-fallback.tsx +8 -26
- package/src/chrome/chrome-slot.tsx +25 -26
- package/src/chrome/code-owned-region.tsx +21 -9
- package/src/chrome/composable-region-client.tsx +228 -0
- package/src/chrome/composable-region.tsx +58 -0
- package/src/chrome/composition-source.ts +34 -0
- package/src/chrome/composition-state.ts +49 -0
- package/src/chrome/live-checkout-page-context.tsx +3 -2
- package/src/chrome/placeholder-slugs.ts +14 -4
- package/src/chrome/route-slug.ts +6 -3
- package/src/hooks/use-designer-features.tsx +41 -10
- package/src/index.ts +6 -0
- package/src/utils/__tests__/checkout-safety.test.ts +374 -0
- package/src/utils/checkout-safety.ts +177 -0
- package/src/utils/index.ts +47 -8
- package/src/utils/part-styles.ts +4 -0
- package/src/utils/visibility-rules.ts +31 -8
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level registry connecting checkout outlet BLOCKS to checkout PIECES.
|
|
3
|
+
*
|
|
4
|
+
* The two sides live in sibling React subtrees with no shared ancestor to
|
|
5
|
+
* carry this state: outlets render inside the `checkout-page-body` ChromeSlot
|
|
6
|
+
* subtree, while the pieces (the real, code-owned checkout components) render
|
|
7
|
+
* in the checkout page's `{children}` subtree. Like the overlay stack in
|
|
8
|
+
* `hooks/use-overlay`, the store is therefore module state — one queue both
|
|
9
|
+
* subtrees can see.
|
|
10
|
+
*
|
|
11
|
+
* Claim policy is FIRST-WINS: outlets register in insertion (mount) order and
|
|
12
|
+
* the first still-alive registration for a piece is the claim. A duplicate
|
|
13
|
+
* outlet for the same piece keeps rendering its empty target div and, in the
|
|
14
|
+
* designer, explains itself; it never steals the piece.
|
|
15
|
+
*
|
|
16
|
+
* No JSX and no 'use client' directive here on purpose — this module is only
|
|
17
|
+
* ever imported from client modules (the piece wrapper and the outlet blocks).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type CheckoutPieceKey = 'flow' | 'steps' | 'summary';
|
|
21
|
+
|
|
22
|
+
export const CHECKOUT_PIECE_KEYS: readonly CheckoutPieceKey[] = [
|
|
23
|
+
'flow',
|
|
24
|
+
'steps',
|
|
25
|
+
'summary'
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A composition's checkout-layout preference, carried by the flow outlet.
|
|
30
|
+
* DELIBERATELY closed to the two standalone families: the unified
|
|
31
|
+
* basket-and-checkout / accordion experiences are settings-only, so a
|
|
32
|
+
* composition can never change unified-ness (and can never create a
|
|
33
|
+
* basket-to-checkout redirect loop). 'inherit' is not a value here — it is
|
|
34
|
+
* expressed as an absent preference.
|
|
35
|
+
*/
|
|
36
|
+
export type CheckoutFlowVariantPreference = 'one-page' | 'step-by-step';
|
|
37
|
+
|
|
38
|
+
export interface CheckoutOutletRegistration {
|
|
39
|
+
/** Outlet instance id (the `useId` of the outlet block). */
|
|
40
|
+
id: string;
|
|
41
|
+
/** The portal target div; identity never changes for the outlet's life. */
|
|
42
|
+
node: HTMLElement;
|
|
43
|
+
/**
|
|
44
|
+
* The flow outlet's composed checkout-layout preference. Absent means
|
|
45
|
+
* inherit (keep the storefront's configured checkout). Only ever read
|
|
46
|
+
* through `getCheckoutFlowVariantOverride`, so only the first-wins flow
|
|
47
|
+
* claim can steer the variant.
|
|
48
|
+
*/
|
|
49
|
+
flowVariant?: CheckoutFlowVariantPreference;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Insertion order = claim priority (first-wins). */
|
|
53
|
+
const outletsByPiece = new Map<
|
|
54
|
+
CheckoutPieceKey,
|
|
55
|
+
CheckoutOutletRegistration[]
|
|
56
|
+
>();
|
|
57
|
+
|
|
58
|
+
/** Ref-counted piece presence — a piece announces on mount, retracts on unmount. */
|
|
59
|
+
const pieceRefCounts = new Map<CheckoutPieceKey, number>();
|
|
60
|
+
|
|
61
|
+
const listeners = new Set<() => void>();
|
|
62
|
+
|
|
63
|
+
const notify = () => {
|
|
64
|
+
listeners.forEach((listener) => listener());
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const registerCheckoutOutlet = (
|
|
68
|
+
piece: CheckoutPieceKey,
|
|
69
|
+
registration: CheckoutOutletRegistration
|
|
70
|
+
): void => {
|
|
71
|
+
const outlets = outletsByPiece.get(piece) ?? [];
|
|
72
|
+
// A re-render re-running the outlet's effect must not queue a second claim.
|
|
73
|
+
if (outlets.some((existing) => existing.id === registration.id)) return;
|
|
74
|
+
|
|
75
|
+
outletsByPiece.set(piece, [...outlets, registration]);
|
|
76
|
+
notify();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const unregisterCheckoutOutlet = (
|
|
80
|
+
piece: CheckoutPieceKey,
|
|
81
|
+
id: string
|
|
82
|
+
): void => {
|
|
83
|
+
const outlets = outletsByPiece.get(piece);
|
|
84
|
+
if (!outlets) return;
|
|
85
|
+
|
|
86
|
+
const index = outlets.findIndex((existing) => existing.id === id);
|
|
87
|
+
if (index === -1) return;
|
|
88
|
+
|
|
89
|
+
outlets.splice(index, 1);
|
|
90
|
+
notify();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Designer-time property edits update the preference of an already-registered
|
|
95
|
+
* outlet IN PLACE — the registration keeps its queue position (no reorder, so
|
|
96
|
+
* first-wins never flips) and its node identity. Unknown ids are a no-op.
|
|
97
|
+
*/
|
|
98
|
+
export const updateCheckoutOutletFlowVariant = (
|
|
99
|
+
piece: CheckoutPieceKey,
|
|
100
|
+
id: string,
|
|
101
|
+
flowVariant?: CheckoutFlowVariantPreference
|
|
102
|
+
): void => {
|
|
103
|
+
const registration = outletsByPiece
|
|
104
|
+
.get(piece)
|
|
105
|
+
?.find((existing) => existing.id === id);
|
|
106
|
+
if (!registration || registration.flowVariant === flowVariant) return;
|
|
107
|
+
|
|
108
|
+
registration.flowVariant = flowVariant;
|
|
109
|
+
notify();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** First registered, still-alive outlet for the piece — first-wins. Null when none. */
|
|
113
|
+
export const getCheckoutOutletClaim = (
|
|
114
|
+
piece: CheckoutPieceKey
|
|
115
|
+
): CheckoutOutletRegistration | null => outletsByPiece.get(piece)?.[0] ?? null;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The composed checkout-layout preference, if any: the first-wins flow claim's
|
|
119
|
+
* preference or null. First-wins comes for free — duplicates never steer.
|
|
120
|
+
*/
|
|
121
|
+
export const getCheckoutFlowVariantOverride =
|
|
122
|
+
(): CheckoutFlowVariantPreference | null =>
|
|
123
|
+
getCheckoutOutletClaim('flow')?.flowVariant ?? null;
|
|
124
|
+
|
|
125
|
+
export const isCheckoutOutletWinner = (
|
|
126
|
+
piece: CheckoutPieceKey,
|
|
127
|
+
id: string
|
|
128
|
+
): boolean => getCheckoutOutletClaim(piece)?.id === id;
|
|
129
|
+
|
|
130
|
+
export const announceCheckoutPiece = (piece: CheckoutPieceKey): void => {
|
|
131
|
+
pieceRefCounts.set(piece, (pieceRefCounts.get(piece) ?? 0) + 1);
|
|
132
|
+
notify();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const retractCheckoutPiece = (piece: CheckoutPieceKey): void => {
|
|
136
|
+
const count = pieceRefCounts.get(piece) ?? 0;
|
|
137
|
+
// A stray retract must not push the count negative and mask a later mount.
|
|
138
|
+
if (count === 0) return;
|
|
139
|
+
|
|
140
|
+
pieceRefCounts.set(piece, count - 1);
|
|
141
|
+
notify();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const isCheckoutPieceMounted = (piece: CheckoutPieceKey): boolean =>
|
|
145
|
+
(pieceRefCounts.get(piece) ?? 0) > 0;
|
|
146
|
+
|
|
147
|
+
export const subscribeCheckoutPieces = (listener: () => void): (() => void) => {
|
|
148
|
+
listeners.add(listener);
|
|
149
|
+
|
|
150
|
+
return () => {
|
|
151
|
+
listeners.delete(listener);
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Server snapshot helpers: on the server nothing is claimed and nothing is mounted. */
|
|
156
|
+
export const noClaimOnServer = (): null => null;
|
|
157
|
+
export const noPieceOnServer = (): boolean => false;
|
|
158
|
+
export const noFlowVariantOnServer = (): null => null;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The editor-proven designer flag, set only after a trusted
|
|
162
|
+
* SET_THEME_EDITOR_COOKIE through the origin-guarded bridge (see
|
|
163
|
+
* theme-placeholder-client.tsx) — being framed is never enough.
|
|
164
|
+
*/
|
|
165
|
+
export const isThemeEditorDesigner = (): boolean =>
|
|
166
|
+
typeof window !== 'undefined' && window.__themeEditorDesigner === true;
|
|
167
|
+
|
|
168
|
+
/** Test helper — clears all outlets and piece counts. */
|
|
169
|
+
export const resetCheckoutPiecesForTests = (): void => {
|
|
170
|
+
outletsByPiece.clear();
|
|
171
|
+
pieceRefCounts.clear();
|
|
172
|
+
notify();
|
|
173
|
+
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { useEffect, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { isTrustedEditorEvent, postToEditor } from '../editor-bridge';
|
|
6
|
+
import { isSlugComposedInFrame } from './composition-state';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Wraps the brand's original chrome markup.
|
|
@@ -66,32 +67,13 @@ export function ChromeFallback({
|
|
|
66
67
|
const { type, data } = event.data || {};
|
|
67
68
|
if (type !== 'UPDATE_THEME' && type !== 'LOAD_THEME') return;
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
);
|
|
76
|
-
const sections = placeholder?.sections;
|
|
77
|
-
// Mirror the server's takeover rule exactly: only a *composed* section
|
|
78
|
-
// replaces the project markup, and a composed section is one that reached
|
|
79
|
-
// the saved order.
|
|
80
|
-
//
|
|
81
|
-
// The `native` exclusion guards against the retired native widget path
|
|
82
|
-
// rather than supporting it. That path styled a project component in place
|
|
83
|
-
// instead of replacing it, so its sections never entered the order; the
|
|
84
|
-
// runtime is gone, but the editor can still hold such a section in a
|
|
85
|
-
// stored theme and push it here, and withdrawing the project markup for
|
|
86
|
-
// one would show the designer an empty area no visitor ever sees. It goes
|
|
87
|
-
// when the editor-side machinery does.
|
|
88
|
-
setIsOverridden(
|
|
89
|
-
Array.isArray(sections) &&
|
|
90
|
-
sections.some(
|
|
91
|
-
(section: { hidden?: boolean; type?: string }) =>
|
|
92
|
-
!section?.hidden && section?.type !== 'native'
|
|
93
|
-
)
|
|
94
|
-
);
|
|
70
|
+
// Shared with ComposableRegion so the two can never disagree about
|
|
71
|
+
// whether one slug is composed; `null` means the frame said nothing
|
|
72
|
+
// about placeholders, so the current decision stands.
|
|
73
|
+
const composed = isSlugComposedInFrame(data, slug);
|
|
74
|
+
if (composed === null) return;
|
|
75
|
+
|
|
76
|
+
setIsOverridden(composed);
|
|
95
77
|
};
|
|
96
78
|
|
|
97
79
|
window.addEventListener('message', handleMessage);
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { getWidgetData } from '@akinon/next/data/server';
|
|
3
2
|
import ThemePlaceholder from '../theme-placeholder';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
warnAboutUncomposedSlot
|
|
7
|
-
} from './composition-state';
|
|
3
|
+
import { warnAboutUncomposedSlot } from './composition-state';
|
|
4
|
+
import { getSlugCompositionState } from './composition-source';
|
|
8
5
|
import { ChromeFallback } from './chrome-fallback';
|
|
9
6
|
|
|
10
7
|
interface ChromeSlotProps {
|
|
@@ -30,6 +27,13 @@ interface ChromeSlotProps {
|
|
|
30
27
|
* classes; the designer overlay keeps a real box either way.
|
|
31
28
|
*/
|
|
32
29
|
fallbackClassName?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Set false when something ELSE on the page already carries this slug's
|
|
32
|
+
* takeover affordance — the checkout body, whose ComposableRegion offers it
|
|
33
|
+
* over the live flow. A fallback-less slot then draws no designer band at
|
|
34
|
+
* all: two competing CTAs for one slug is worse than none.
|
|
35
|
+
*/
|
|
36
|
+
emptyZoneBand?: boolean;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
/**
|
|
@@ -43,24 +47,17 @@ export default async function ChromeSlot({
|
|
|
43
47
|
fallback,
|
|
44
48
|
pageContext = null,
|
|
45
49
|
className,
|
|
46
|
-
fallbackClassName
|
|
50
|
+
fallbackClassName,
|
|
51
|
+
emptyZoneBand = true
|
|
47
52
|
}: ChromeSlotProps) {
|
|
48
|
-
|
|
53
|
+
// Shared with ComposableRegion and memoized per request, so a slug mounted by
|
|
54
|
+
// both is fetched once and both reach the same conclusion.
|
|
55
|
+
const state = await getSlugCompositionState(slug);
|
|
56
|
+
const hasComposition = state.kind === 'composed';
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
theme_editor_placeholder?: unknown;
|
|
54
|
-
}>({ slug });
|
|
55
|
-
|
|
56
|
-
const state = readCompositionState(data);
|
|
57
|
-
hasComposition = state.kind === 'composed';
|
|
58
|
-
// Explain a slot that kept the project markup — see composition-state.ts
|
|
59
|
-
// for why "missing" and "empty" must not read as the same thing.
|
|
60
|
-
warnAboutUncomposedSlot(slug, state);
|
|
61
|
-
} catch {
|
|
62
|
-
// No chrome widget published yet — brand fallback stays active
|
|
63
|
-
}
|
|
58
|
+
// Explain a slot that kept the project markup — see composition-state.ts
|
|
59
|
+
// for why "missing" and "empty" must not read as the same thing.
|
|
60
|
+
warnAboutUncomposedSlot(slug, state);
|
|
64
61
|
|
|
65
62
|
if (hasComposition) {
|
|
66
63
|
return (
|
|
@@ -71,7 +68,9 @@ export default async function ChromeSlot({
|
|
|
71
68
|
header composition would blank the area until a full reload. The
|
|
72
69
|
same applies to a fallback-less zone: its designer band has to be
|
|
73
70
|
able to return, or removing the last section leaves the area
|
|
74
|
-
undiscoverable again.
|
|
71
|
+
undiscoverable again. A zone whose band is suppressed needs nothing
|
|
72
|
+
here — the ComposableRegion carrying its affordance follows the same
|
|
73
|
+
theme signal and comes back on its own. */}
|
|
75
74
|
{fallback ? (
|
|
76
75
|
<ChromeFallback
|
|
77
76
|
slug={slug}
|
|
@@ -80,9 +79,9 @@ export default async function ChromeSlot({
|
|
|
80
79
|
>
|
|
81
80
|
{fallback}
|
|
82
81
|
</ChromeFallback>
|
|
83
|
-
) : (
|
|
82
|
+
) : emptyZoneBand ? (
|
|
84
83
|
<ChromeFallback slug={slug} emptyZone initiallyOverridden />
|
|
85
|
-
)}
|
|
84
|
+
) : null}
|
|
86
85
|
<ThemePlaceholder slug={slug} pageContext={pageContext} />
|
|
87
86
|
</div>
|
|
88
87
|
);
|
|
@@ -94,12 +93,12 @@ export default async function ChromeSlot({
|
|
|
94
93
|
<ChromeFallback slug={slug} wrapperClassName={fallbackClassName}>
|
|
95
94
|
{fallback}
|
|
96
95
|
</ChromeFallback>
|
|
97
|
-
) : (
|
|
96
|
+
) : emptyZoneBand ? (
|
|
98
97
|
// No brand markup to take over: an uncomposed zone would render as
|
|
99
98
|
// nothing at all, so the designer gets a visible band instead. The
|
|
100
99
|
// live site keeps rendering nothing.
|
|
101
100
|
<ChromeFallback slug={slug} emptyZone />
|
|
102
|
-
)}
|
|
101
|
+
) : null}
|
|
103
102
|
{/* Empty until the designer overrides this area from the editor */}
|
|
104
103
|
<ThemePlaceholder slug={slug} pageContext={pageContext} />
|
|
105
104
|
</div>
|
|
@@ -3,23 +3,35 @@
|
|
|
3
3
|
import React, { useEffect, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Wraps markup that is deliberately
|
|
7
|
-
*
|
|
8
|
-
* no feedback
|
|
9
|
-
* other region either offers the takeover
|
|
6
|
+
* Wraps markup that is deliberately not editable at all — no takeover, no
|
|
7
|
+
* placement, nothing but an explanation. Without this wrapper, hovering the
|
|
8
|
+
* area in the designer gives no feedback whatsoever, which reads as "broken"
|
|
9
|
+
* rather than "code-owned": every other region either offers the takeover
|
|
10
|
+
* affordance or an empty-zone band.
|
|
11
|
+
*
|
|
12
|
+
* The default sentence promises nothing beyond that. A mount whose area DOES
|
|
13
|
+
* have a composable body zone must pass its own `note` saying so — the promise
|
|
14
|
+
* used to be baked into the default, and it was false for any mount without
|
|
15
|
+
* one (outlet blocks only register inside the `checkout-page-body` zone, so
|
|
16
|
+
* with no such zone in the layout no composition can move anything).
|
|
17
|
+
*
|
|
18
|
+
* An area that can be taken over wholesale wants `ComposableRegion` instead:
|
|
19
|
+
* same non-destructive wrapper, but with the real affordance.
|
|
10
20
|
*
|
|
11
21
|
* On the live site the wrapper is inert (one attribute-carrying div, no
|
|
12
22
|
* styles beyond `position: relative`). In the designer, hover draws a neutral
|
|
13
|
-
* outline and
|
|
14
|
-
*
|
|
15
|
-
* so the wrapped flow stays fully usable in the preview.
|
|
23
|
+
* outline and the note. The overlay never intercepts the pointer, so the
|
|
24
|
+
* wrapped flow stays fully usable in the preview.
|
|
16
25
|
*/
|
|
17
26
|
export function CodeOwnedRegion({
|
|
18
27
|
label,
|
|
28
|
+
note,
|
|
19
29
|
children
|
|
20
30
|
}: {
|
|
21
31
|
/** Human-readable region name, e.g. "checkout flow". */
|
|
22
32
|
label: string;
|
|
33
|
+
/** Replaces the default hover-pill sentence with region-specific wording. */
|
|
34
|
+
note?: string;
|
|
23
35
|
children: React.ReactNode;
|
|
24
36
|
}) {
|
|
25
37
|
const [inDesigner, setInDesigner] = useState(false);
|
|
@@ -75,8 +87,8 @@ export function CodeOwnedRegion({
|
|
|
75
87
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.25)'
|
|
76
88
|
}}
|
|
77
89
|
>
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
{note ??
|
|
91
|
+
`The ${label} is code-owned — its internals cannot be edited here`}
|
|
80
92
|
</span>
|
|
81
93
|
</div>
|
|
82
94
|
)}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { isTrustedEditorEvent, postToEditor } from '../editor-bridge';
|
|
6
|
+
import { isThemeEditorDesigner } from './checkout-pieces';
|
|
7
|
+
import { isSlugComposedInFrame } from './composition-state';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The designer-facing half of `ComposableRegion`. See that file for what the
|
|
11
|
+
* component is for; everything below is why it is shaped the way it is.
|
|
12
|
+
*
|
|
13
|
+
* It deliberately contains NO `return null` on any path. ChromeFallback's whole
|
|
14
|
+
* contract is to disappear when the area is composed, which is exactly wrong
|
|
15
|
+
* here: the children are the real checkout flow, and losing them would lose the
|
|
16
|
+
* customer's payment session. Keeping the children unconditionally mounted is a
|
|
17
|
+
* structural property of this file rather than a rule someone has to remember.
|
|
18
|
+
*
|
|
19
|
+
* For the same reason the wrapper element is always the same single div: no
|
|
20
|
+
* conditional wrapper, no key change, no element appearing when the designer
|
|
21
|
+
* flag arrives after hydration. Only inline style VALUES flip, so the subtree
|
|
22
|
+
* below never remounts — a remount would refetch checkout state and could land
|
|
23
|
+
* mid-3DS.
|
|
24
|
+
*/
|
|
25
|
+
export function ComposableRegionClient({
|
|
26
|
+
slug,
|
|
27
|
+
label,
|
|
28
|
+
composedNote,
|
|
29
|
+
initiallyComposed = false,
|
|
30
|
+
children
|
|
31
|
+
}: {
|
|
32
|
+
/** Placeholder slug this area hands over to, e.g. `checkout-page-body`. */
|
|
33
|
+
slug: string;
|
|
34
|
+
/** Hover-pill sentence; defaults to a generic take-it-over line. */
|
|
35
|
+
label?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Hover-pill sentence shown once the area IS composed, when the live content
|
|
38
|
+
* is still rendering here (nothing in the composition placed it elsewhere).
|
|
39
|
+
* Defaults to a generic "composed, and inert on the canvas" line.
|
|
40
|
+
*/
|
|
41
|
+
composedNote?: string;
|
|
42
|
+
/**
|
|
43
|
+
* True when the server rendered a composition for this slug. The affordance
|
|
44
|
+
* then starts hidden — the composition already owns the area — and comes back
|
|
45
|
+
* if the designer removes its last section.
|
|
46
|
+
*/
|
|
47
|
+
initiallyComposed?: boolean;
|
|
48
|
+
children: React.ReactNode;
|
|
49
|
+
}) {
|
|
50
|
+
const [inDesigner, setInDesigner] = useState(false);
|
|
51
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
52
|
+
const [isComposed, setIsComposed] = useState(initiallyComposed);
|
|
53
|
+
|
|
54
|
+
// Designer detection uses the editor-proven flag, NOT a framing check.
|
|
55
|
+
// Being framed is not the same as being edited: pz-next itself ships
|
|
56
|
+
// `frame-ancestors 'self' … akifast.com … akinoncloud.com`, so a REAL
|
|
57
|
+
// checkout is legitimately embedded in production — and this region makes
|
|
58
|
+
// its area pointer-inert, which on a real checkout is a customer who cannot
|
|
59
|
+
// pay. `window.__themeEditorDesigner` is set only after a trusted
|
|
60
|
+
// SET_THEME_EDITOR_COOKIE arrives through the origin-guarded bridge, which
|
|
61
|
+
// is the same signal `CheckoutPieceBridge` trusts for the same reason.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (isThemeEditorDesigner()) setInDesigner(true);
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
// One listener for both questions the editor answers about this area.
|
|
67
|
+
//
|
|
68
|
+
// Designer flag: it rides a postMessage that can land AFTER this mount, and
|
|
69
|
+
// the editor sends the cookie frame only once per document — so the flag is
|
|
70
|
+
// read on mount above (it survives a late mount) and the frame itself is
|
|
71
|
+
// handled here. Acting on the frame rather than re-reading the flag when it
|
|
72
|
+
// arrives keeps this independent of which listener registered first.
|
|
73
|
+
//
|
|
74
|
+
// Composed state: whether this area is composed is decided server-side at
|
|
75
|
+
// request time, so a takeover that just happened in the editor would
|
|
76
|
+
// otherwise leave the region still advertising itself over the top of the
|
|
77
|
+
// new composition. Follow the theme the editor pushes — and offer the
|
|
78
|
+
// takeover again if the designer removes the last section.
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const handleMessage = (event: MessageEvent) => {
|
|
81
|
+
if (!isTrustedEditorEvent(event)) return;
|
|
82
|
+
|
|
83
|
+
const { type, data } = event.data || {};
|
|
84
|
+
|
|
85
|
+
if (type === 'SET_THEME_EDITOR_COOKIE') {
|
|
86
|
+
if (data) setInDesigner(true);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (type !== 'UPDATE_THEME' && type !== 'LOAD_THEME') return;
|
|
91
|
+
|
|
92
|
+
const composed = isSlugComposedInFrame(data, slug);
|
|
93
|
+
if (composed === null) return;
|
|
94
|
+
|
|
95
|
+
setIsComposed(composed);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
window.addEventListener('message', handleMessage);
|
|
99
|
+
return () => window.removeEventListener('message', handleMessage);
|
|
100
|
+
}, [slug]);
|
|
101
|
+
|
|
102
|
+
const requestOverride = () => {
|
|
103
|
+
postToEditor({ type: 'REQUEST_CHROME_OVERRIDE', data: { slug } });
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div
|
|
108
|
+
data-chrome-region={slug}
|
|
109
|
+
style={{
|
|
110
|
+
position: 'relative',
|
|
111
|
+
// Inert on the canvas whenever the flow renders in place: the capture
|
|
112
|
+
// guards that neutralize clicks, submits and Enter live on the outlet
|
|
113
|
+
// target, which only exists once a composition places one. Without
|
|
114
|
+
// this, a composed-but-unplaced flow would leave a working "place
|
|
115
|
+
// order" button on the canvas. The live site emits no value at all.
|
|
116
|
+
pointerEvents: inDesigner ? 'none' : undefined
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
{children}
|
|
120
|
+
|
|
121
|
+
{/*
|
|
122
|
+
The overlay stays mounted for the whole designer session, composed or
|
|
123
|
+
not — only what it says changes. An overlay that unmounted on takeover
|
|
124
|
+
would leave the inert area with no explanation at all whenever the
|
|
125
|
+
composition does not place the live content elsewhere (an ordinary
|
|
126
|
+
section, or an outlet-free template): visibly dead content, no
|
|
127
|
+
feedback, which reads as a broken preview rather than a guarded one.
|
|
128
|
+
*/}
|
|
129
|
+
{inDesigner && (
|
|
130
|
+
<div
|
|
131
|
+
data-chrome-region-affordance={isComposed ? undefined : ''}
|
|
132
|
+
data-chrome-region-note={isComposed ? '' : undefined}
|
|
133
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
134
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
135
|
+
style={{
|
|
136
|
+
position: 'absolute',
|
|
137
|
+
inset: 0,
|
|
138
|
+
// Hover lives on the overlay, so detecting it never depends on the
|
|
139
|
+
// inert wrapper being a hit target.
|
|
140
|
+
pointerEvents: 'auto',
|
|
141
|
+
display: 'flex',
|
|
142
|
+
alignItems: 'flex-start',
|
|
143
|
+
justifyContent: 'center',
|
|
144
|
+
// Composed: nothing to click, so no call-to-action scrim — just a
|
|
145
|
+
// neutral outline, the same "explained, not offered" register the
|
|
146
|
+
// code-owned note uses.
|
|
147
|
+
background:
|
|
148
|
+
isHovered && !isComposed
|
|
149
|
+
? 'rgba(15, 23, 42, 0.55)'
|
|
150
|
+
: 'transparent',
|
|
151
|
+
outline: isHovered
|
|
152
|
+
? isComposed
|
|
153
|
+
? '2px dashed #cbd5e1'
|
|
154
|
+
: '2px dashed #60a5fa'
|
|
155
|
+
: 'none',
|
|
156
|
+
outlineOffset: '-2px',
|
|
157
|
+
transition: 'background 120ms ease',
|
|
158
|
+
zIndex: 30
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
{isHovered &&
|
|
162
|
+
(isComposed ? (
|
|
163
|
+
<span
|
|
164
|
+
style={{
|
|
165
|
+
// Sticky keeps the note readable while a tall region (the
|
|
166
|
+
// whole checkout flow) is scrolled under the pointer.
|
|
167
|
+
position: 'sticky',
|
|
168
|
+
top: '12px',
|
|
169
|
+
marginTop: '12px',
|
|
170
|
+
background: '#0f172a',
|
|
171
|
+
color: '#ffffff',
|
|
172
|
+
borderRadius: '9999px',
|
|
173
|
+
padding: '6px 14px',
|
|
174
|
+
fontSize: '12.5px',
|
|
175
|
+
fontWeight: 500,
|
|
176
|
+
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.25)'
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
{composedNote ??
|
|
180
|
+
`This area is composed — the content below stays code-owned and is inert on the canvas`}
|
|
181
|
+
</span>
|
|
182
|
+
) : (
|
|
183
|
+
<div
|
|
184
|
+
style={{
|
|
185
|
+
// The checkout body is tall: a vertically centered control
|
|
186
|
+
// lands off-screen, so the affordance rides the top edge.
|
|
187
|
+
position: 'sticky',
|
|
188
|
+
top: '12px',
|
|
189
|
+
marginTop: '12px',
|
|
190
|
+
display: 'flex',
|
|
191
|
+
alignItems: 'center',
|
|
192
|
+
gap: '12px'
|
|
193
|
+
}}
|
|
194
|
+
>
|
|
195
|
+
<span
|
|
196
|
+
style={{
|
|
197
|
+
color: '#ffffff',
|
|
198
|
+
fontSize: '13px',
|
|
199
|
+
fontWeight: 500,
|
|
200
|
+
textShadow: '0 1px 2px rgba(0,0,0,0.4)'
|
|
201
|
+
}}
|
|
202
|
+
>
|
|
203
|
+
{label ??
|
|
204
|
+
`The ${slug} area is not composed yet — take it over to edit it here`}
|
|
205
|
+
</span>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={requestOverride}
|
|
209
|
+
style={{
|
|
210
|
+
background: '#2563eb',
|
|
211
|
+
color: '#ffffff',
|
|
212
|
+
border: 'none',
|
|
213
|
+
borderRadius: '6px',
|
|
214
|
+
padding: '8px 14px',
|
|
215
|
+
fontSize: '13px',
|
|
216
|
+
fontWeight: 600,
|
|
217
|
+
cursor: 'pointer'
|
|
218
|
+
}}
|
|
219
|
+
>
|
|
220
|
+
Edit in theme editor
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
))}
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { ComposableRegionClient } from './composable-region-client';
|
|
4
|
+
import { getSlugCompositionState } from './composition-source';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wraps live, code-owned markup that a composition may TAKE OVER — the checkout
|
|
8
|
+
* flow being the case it exists for.
|
|
9
|
+
*
|
|
10
|
+
* Every other composable area offers its takeover through the markup a
|
|
11
|
+
* ChromeSlot can withdraw: hover the project's header, press "Edit in theme
|
|
12
|
+
* editor", the composition replaces it. Checkout cannot work that way. Its flow
|
|
13
|
+
* must never be replaced or unmounted — a composition arranges where the flow,
|
|
14
|
+
* steps and summary render through outlet blocks, and the flow itself always
|
|
15
|
+
* survives — so the slot that renders the composition carries no fallback and
|
|
16
|
+
* the flow stays outside it. That left the checkout body advertising itself
|
|
17
|
+
* with a slim dashed band while the actual page content underneath answered a
|
|
18
|
+
* hover with nothing, which reads as "this page is not editable".
|
|
19
|
+
*
|
|
20
|
+
* This region closes that gap: hovering the real checkout content in the
|
|
21
|
+
* designer offers the same scrim and the same "Edit in theme editor" button
|
|
22
|
+
* every other page has, posting `REQUEST_CHROME_OVERRIDE` for the body slug so
|
|
23
|
+
* the editor opens its template picker. The children are never withdrawn in any
|
|
24
|
+
* state — see `composable-region-client.tsx` for why that is the point.
|
|
25
|
+
*
|
|
26
|
+
* Mount it AFTER the slot for the same slug: outlet blocks inside the
|
|
27
|
+
* composition must register before the flow's mount latch runs.
|
|
28
|
+
*/
|
|
29
|
+
export default async function ComposableRegion({
|
|
30
|
+
slug,
|
|
31
|
+
label,
|
|
32
|
+
composedNote,
|
|
33
|
+
children
|
|
34
|
+
}: {
|
|
35
|
+
/** Placeholder slug this area hands over to, e.g. `checkout-page-body`. */
|
|
36
|
+
slug: string;
|
|
37
|
+
/** Hover-pill sentence; defaults to a generic take-it-over line. */
|
|
38
|
+
label?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Hover-pill sentence for the composed state, when the live content still
|
|
41
|
+
* renders here because nothing in the composition placed it elsewhere.
|
|
42
|
+
*/
|
|
43
|
+
composedNote?: string;
|
|
44
|
+
children: React.ReactNode;
|
|
45
|
+
}) {
|
|
46
|
+
const state = await getSlugCompositionState(slug);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<ComposableRegionClient
|
|
50
|
+
slug={slug}
|
|
51
|
+
label={label}
|
|
52
|
+
composedNote={composedNote}
|
|
53
|
+
initiallyComposed={state.kind === 'composed'}
|
|
54
|
+
>
|
|
55
|
+
{children}
|
|
56
|
+
</ComposableRegionClient>
|
|
57
|
+
);
|
|
58
|
+
}
|