@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,104 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
5
|
+
import { CheckoutOutletBlock } from './checkout-outlet-base';
|
|
6
|
+
import { CheckoutFlowVariantPreference } from '../chrome/checkout-pieces';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parts HOSTED by this outlet: stamped (`partAttrs`) by the code-owned
|
|
10
|
+
* checkout flow that teleports here, never rendered by the outlet itself.
|
|
11
|
+
* The editor's descriptor conformance reads this literal, and
|
|
12
|
+
* `checkout-hosted-parts.test.ts` verifies every name is honestly stamped in
|
|
13
|
+
* the app's checkout views — extend both sides together or the tests fail.
|
|
14
|
+
*
|
|
15
|
+
* The `summary-*` names exist here AND on the summary outlet on purpose, and
|
|
16
|
+
* the flow outlet declares the WHOLE summary vocabulary, not a subset:
|
|
17
|
+
*
|
|
18
|
+
* - `MobileSummarySheet` re-renders the full `OnePageSummary` inside the flow
|
|
19
|
+
* subtree, and part rules are block-scoped, so the summary outlet's rules
|
|
20
|
+
* can never reach the sheet;
|
|
21
|
+
* - when a composition places no summary outlet, the unclaimed summary piece
|
|
22
|
+
* renders in its default in-place position — which is inside the flow — so
|
|
23
|
+
* the desktop rail lands in this subtree too.
|
|
24
|
+
*
|
|
25
|
+
* Declaring only some of the names left the rest with no reachable control at
|
|
26
|
+
* all in those compositions, and made a click on the sheet's card fall back
|
|
27
|
+
* to the block panel with nothing explaining why.
|
|
28
|
+
*/
|
|
29
|
+
export const HOSTED_PARTS = [
|
|
30
|
+
'section',
|
|
31
|
+
'section-header',
|
|
32
|
+
'section-number',
|
|
33
|
+
'section-title',
|
|
34
|
+
'edit-button',
|
|
35
|
+
'payment-options',
|
|
36
|
+
'payment-option',
|
|
37
|
+
'payment-option-icon',
|
|
38
|
+
'payment-panel',
|
|
39
|
+
'continue-button',
|
|
40
|
+
'place-order-button',
|
|
41
|
+
'agreement',
|
|
42
|
+
'summary',
|
|
43
|
+
'summary-header',
|
|
44
|
+
'summary-title',
|
|
45
|
+
'summary-item',
|
|
46
|
+
'summary-row',
|
|
47
|
+
'summary-row-label',
|
|
48
|
+
'summary-row-value',
|
|
49
|
+
'summary-total',
|
|
50
|
+
'summary-total-label',
|
|
51
|
+
'summary-total-value',
|
|
52
|
+
'summary-edit'
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The only values a composition may steer the checkout layout to. The
|
|
57
|
+
* unified basket-and-checkout / accordion experiences are settings-only —
|
|
58
|
+
* a composition can never change unified-ness. Anything else (including
|
|
59
|
+
* 'inherit', absent, non-strings) means inherit and registers no preference.
|
|
60
|
+
*/
|
|
61
|
+
const FLOW_VARIANT_VALUES = new Set(['one-page', 'step-by-step']);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `flowVariant` is breakpoint-invariant (outlet claims and the live latch
|
|
65
|
+
* are breakpoint-unaware): plain strings are taken as-is, per-breakpoint
|
|
66
|
+
* objects read the authoritative desktop value.
|
|
67
|
+
*/
|
|
68
|
+
const resolveFlowVariant = (
|
|
69
|
+
raw: unknown
|
|
70
|
+
): CheckoutFlowVariantPreference | undefined => {
|
|
71
|
+
const candidate =
|
|
72
|
+
typeof raw === 'string'
|
|
73
|
+
? raw
|
|
74
|
+
: (raw as { desktop?: unknown } | null | undefined)?.desktop;
|
|
75
|
+
|
|
76
|
+
return typeof candidate === 'string' && FLOW_VARIANT_VALUES.has(candidate)
|
|
77
|
+
? (candidate as CheckoutFlowVariantPreference)
|
|
78
|
+
: undefined;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Outlet for the ENTIRE checkout flow — auth gate, error and loading states,
|
|
83
|
+
* and the mounted variant with its payment steps, providers and modals. The
|
|
84
|
+
* flow is atomic: no finer granularity exists, and this block only decides
|
|
85
|
+
* WHERE the code-owned flow renders — plus, via `flowVariant`, WHICH of the
|
|
86
|
+
* two standalone layouts the composition prefers — never what the flow does.
|
|
87
|
+
* When no outlet claims the flow, it renders in its default in-place
|
|
88
|
+
* position, so ordering is never impossible.
|
|
89
|
+
*/
|
|
90
|
+
const CheckoutFlowOutletBlock = (props: BlockRendererProps) => {
|
|
91
|
+
const { block } = props;
|
|
92
|
+
const flowVariant = resolveFlowVariant(block.properties?.flowVariant);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<CheckoutOutletBlock
|
|
96
|
+
{...props}
|
|
97
|
+
piece="flow"
|
|
98
|
+
designerLabel="Checkout flow"
|
|
99
|
+
flowVariant={flowVariant}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export default CheckoutFlowOutletBlock;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useEffect,
|
|
5
|
+
useId,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useSyncExternalStore
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
11
|
+
import { DesignerPlaceholder } from './product-block-utils';
|
|
12
|
+
import { PLACEHOLDER_SLUGS } from '../chrome/placeholder-slugs';
|
|
13
|
+
import { postBlockSelection } from '../hooks/use-designer-features';
|
|
14
|
+
import {
|
|
15
|
+
CheckoutFlowVariantPreference,
|
|
16
|
+
CheckoutPieceKey,
|
|
17
|
+
isCheckoutOutletWinner,
|
|
18
|
+
isCheckoutPieceMounted,
|
|
19
|
+
noPieceOnServer,
|
|
20
|
+
registerCheckoutOutlet,
|
|
21
|
+
subscribeCheckoutPieces,
|
|
22
|
+
unregisterCheckoutOutlet,
|
|
23
|
+
updateCheckoutOutletFlowVariant
|
|
24
|
+
} from '../chrome/checkout-pieces';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Shared base for the checkout outlet blocks — positioning targets for the
|
|
28
|
+
* checkout pieces bridge, and nothing more. An outlet renders one empty
|
|
29
|
+
* `div[data-checkout-outlet]` that the matching `CheckoutPiece` wrapper
|
|
30
|
+
* teleports the real, code-owned checkout component into via `createPortal`.
|
|
31
|
+
*
|
|
32
|
+
* SEALED BY DESIGN: this renderer emits no interactive elements, no
|
|
33
|
+
* callbacks, and no props that reach checkout internals. It cannot render
|
|
34
|
+
* payment inputs, agreement checkboxes, or a place-order trigger — the real
|
|
35
|
+
* flow keeps its own React tree and merely lands here in the DOM. Root styles
|
|
36
|
+
* still apply through the `[data-block-id]` wrapper, and scoped custom CSS may
|
|
37
|
+
* restyle the teleported markup; restyling is accepted — it cannot synthesize
|
|
38
|
+
* behavior.
|
|
39
|
+
*
|
|
40
|
+
* ZONE GATE: an outlet only registers when it renders inside the
|
|
41
|
+
* `checkout-page-body` placeholder. The flow piece latches its teleport
|
|
42
|
+
* decision one-shot at page mount, and only the body slot is guaranteed to
|
|
43
|
+
* hydrate before that latch — an outlet composed into a trailing zone
|
|
44
|
+
* (Summary Extras, Bottom) registers after the latch and would be silently
|
|
45
|
+
* dead on the live site while the designer canvas happily teleported into it.
|
|
46
|
+
* Refusing to register outside the body zone keeps the canvas and the
|
|
47
|
+
* storefront telling the same story everywhere (including non-checkout
|
|
48
|
+
* pages), and the designer band below explains why instead of promising a
|
|
49
|
+
* render that would never happen.
|
|
50
|
+
*
|
|
51
|
+
* Live behavior when the outlet is unclaimed, out of zone, or the piece is
|
|
52
|
+
* absent: the single empty div, nothing else (the piece then renders in its
|
|
53
|
+
* default in-place position, so ordering is never lost). Duplicate outlets
|
|
54
|
+
* for the same piece resolve first-wins. A composition re-publish applies to
|
|
55
|
+
* checkout on the next full page load — live pages never re-target a latched
|
|
56
|
+
* piece.
|
|
57
|
+
*
|
|
58
|
+
* The target div is rendered UNCONDITIONALLY and its identity never changes
|
|
59
|
+
* for the outlet's life: a portaled flow may host payment iframes and 3DS
|
|
60
|
+
* containers, and swapping the target node would remount them mid-payment.
|
|
61
|
+
*
|
|
62
|
+
* HIDDEN GATE: a hidden outlet never registers. Live already drops hidden
|
|
63
|
+
* blocks before they mount (theme-section filters them out), but the designer
|
|
64
|
+
* deliberately keeps them mounted under a `display:none` wrapper so a
|
|
65
|
+
* visibility toggle does not remount half the page. Without this gate the
|
|
66
|
+
* hidden outlet still won the first-wins claim on the canvas and the piece
|
|
67
|
+
* portaled the real flow into an invisible subtree — checkout vanished from
|
|
68
|
+
* the canvas while the storefront rendered it in its default position. The
|
|
69
|
+
* descriptor promises the opposite ("its hidden flag stops it from claiming
|
|
70
|
+
* at all"), so the gate makes both sides tell that same story.
|
|
71
|
+
*
|
|
72
|
+
* DESIGNER INPUT CAPTURE: the teleported markup is code-owned — none of our
|
|
73
|
+
* React handlers exist inside it. When `isDesigner`, native capture listeners
|
|
74
|
+
* on the target div neutralize the interactions that could reach a live
|
|
75
|
+
* handler, so payment option POSTs, step navigation and order submits can
|
|
76
|
+
* never fire on the canvas:
|
|
77
|
+
*
|
|
78
|
+
* - `click` is neutralized (preventDefault + stopPropagation) and selects the
|
|
79
|
+
* clicked `[data-part]` in the editor panel;
|
|
80
|
+
* - `submit` is neutralized too. A click guard alone does not cover it:
|
|
81
|
+
* pressing Enter inside a card-number field triggers IMPLICIT form
|
|
82
|
+
* submission, which dispatches `submit` without any click at all;
|
|
83
|
+
* - `keydown` of Enter is default-prevented (without stopping propagation, so
|
|
84
|
+
* editor shortcuts still see it) — that is the default action implicit
|
|
85
|
+
* submission rides on.
|
|
86
|
+
*
|
|
87
|
+
* The listeners exist ONLY in the designer: live pages get zero listeners and
|
|
88
|
+
* zero behavior change.
|
|
89
|
+
*/
|
|
90
|
+
export function CheckoutOutletBlock({
|
|
91
|
+
piece,
|
|
92
|
+
designerLabel,
|
|
93
|
+
flowVariant,
|
|
94
|
+
designerNote,
|
|
95
|
+
...props
|
|
96
|
+
}: BlockRendererProps & {
|
|
97
|
+
piece: CheckoutPieceKey;
|
|
98
|
+
designerLabel: string;
|
|
99
|
+
/** The flow outlet's validated composed layout preference (absent = inherit). */
|
|
100
|
+
flowVariant?: CheckoutFlowVariantPreference;
|
|
101
|
+
/** Extra designer-only band explaining outlet-specific caveats. */
|
|
102
|
+
designerNote?: string | null;
|
|
103
|
+
}) {
|
|
104
|
+
const outletId = useId();
|
|
105
|
+
const targetRef = useRef<HTMLDivElement | null>(null);
|
|
106
|
+
const inBodyZone = props.placeholderId === PLACEHOLDER_SLUGS.checkoutBody;
|
|
107
|
+
// Live never even mounts a hidden block; the designer does, so the claim
|
|
108
|
+
// has to be refused here for the two sides to agree.
|
|
109
|
+
const isHidden = Boolean(props.block.hidden);
|
|
110
|
+
const canClaim = inBodyZone && !isHidden;
|
|
111
|
+
|
|
112
|
+
// Read at register time via a ref so a designer property edit never
|
|
113
|
+
// re-runs the register effect (re-registering would drop and re-queue the
|
|
114
|
+
// claim, churning first-wins order and the live latch).
|
|
115
|
+
const flowVariantRef = useRef(flowVariant);
|
|
116
|
+
flowVariantRef.current = flowVariant;
|
|
117
|
+
|
|
118
|
+
useLayoutEffect(() => {
|
|
119
|
+
if (!canClaim || !targetRef.current) return;
|
|
120
|
+
|
|
121
|
+
registerCheckoutOutlet(piece, {
|
|
122
|
+
id: outletId,
|
|
123
|
+
node: targetRef.current,
|
|
124
|
+
flowVariant: flowVariantRef.current
|
|
125
|
+
});
|
|
126
|
+
return () => unregisterCheckoutOutlet(piece, outletId);
|
|
127
|
+
}, [piece, outletId, canClaim]);
|
|
128
|
+
|
|
129
|
+
// Designer-time property edits update the registration in place (no
|
|
130
|
+
// re-register, no reorder) so the canvas previews the new variant
|
|
131
|
+
// immediately. On mount this runs after registration with the same value —
|
|
132
|
+
// a no-op by design.
|
|
133
|
+
useLayoutEffect(() => {
|
|
134
|
+
if (!canClaim) return;
|
|
135
|
+
|
|
136
|
+
updateCheckoutOutletFlowVariant(piece, outletId, flowVariant);
|
|
137
|
+
}, [piece, outletId, canClaim, flowVariant]);
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!props.isDesigner) return;
|
|
141
|
+
|
|
142
|
+
const node = targetRef.current;
|
|
143
|
+
if (!node) return;
|
|
144
|
+
|
|
145
|
+
const handleCanvasClick = (event: MouseEvent) => {
|
|
146
|
+
// Neutralize FIRST, unconditionally: no click inside teleported
|
|
147
|
+
// checkout markup may reach a live handler on the canvas.
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
event.stopPropagation();
|
|
150
|
+
|
|
151
|
+
const partElement =
|
|
152
|
+
event.target instanceof Element
|
|
153
|
+
? event.target.closest<HTMLElement>('[data-part]')
|
|
154
|
+
: null;
|
|
155
|
+
// A part is owned by its closest [data-block-id] ancestor — the same
|
|
156
|
+
// rule the CSS ownership guard applies. The teleported markup carries
|
|
157
|
+
// no nested block wrappers, so owned parts resolve to this outlet.
|
|
158
|
+
const ownerWrapper = node.closest('[data-block-id]');
|
|
159
|
+
const partName =
|
|
160
|
+
partElement && partElement.closest('[data-block-id]') === ownerWrapper
|
|
161
|
+
? partElement.getAttribute('data-part')
|
|
162
|
+
: null;
|
|
163
|
+
|
|
164
|
+
postBlockSelection({
|
|
165
|
+
placeholderId: props.placeholderId,
|
|
166
|
+
sectionId: props.sectionId,
|
|
167
|
+
blockId: props.block.id,
|
|
168
|
+
blockType: props.block.type,
|
|
169
|
+
blockLabel: props.block.label,
|
|
170
|
+
partName
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Implicit form submission never produces a click: Enter inside a text
|
|
175
|
+
// field dispatches `submit` straight at the form. Neutralizing it here
|
|
176
|
+
// keeps a canvas keystroke from placing a real order.
|
|
177
|
+
const handleCanvasSubmit = (event: Event) => {
|
|
178
|
+
event.preventDefault();
|
|
179
|
+
event.stopPropagation();
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// The default action Enter carries IS implicit submission, so cancelling
|
|
183
|
+
// it closes the same hole one step earlier. Propagation is deliberately
|
|
184
|
+
// left alone — the editor's own keyboard shortcuts still need the event.
|
|
185
|
+
const handleCanvasKeyDown = (event: KeyboardEvent) => {
|
|
186
|
+
if (event.key === 'Enter') event.preventDefault();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
node.addEventListener('click', handleCanvasClick, true);
|
|
190
|
+
node.addEventListener('submit', handleCanvasSubmit, true);
|
|
191
|
+
node.addEventListener('keydown', handleCanvasKeyDown, true);
|
|
192
|
+
|
|
193
|
+
return () => {
|
|
194
|
+
node.removeEventListener('click', handleCanvasClick, true);
|
|
195
|
+
node.removeEventListener('submit', handleCanvasSubmit, true);
|
|
196
|
+
node.removeEventListener('keydown', handleCanvasKeyDown, true);
|
|
197
|
+
};
|
|
198
|
+
}, [
|
|
199
|
+
props.isDesigner,
|
|
200
|
+
props.placeholderId,
|
|
201
|
+
props.sectionId,
|
|
202
|
+
props.block.id,
|
|
203
|
+
props.block.type,
|
|
204
|
+
props.block.label
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
const isWinner = useSyncExternalStore(
|
|
208
|
+
subscribeCheckoutPieces,
|
|
209
|
+
() => isCheckoutOutletWinner(piece, outletId),
|
|
210
|
+
noPieceOnServer
|
|
211
|
+
);
|
|
212
|
+
const pieceMounted = useSyncExternalStore(
|
|
213
|
+
subscribeCheckoutPieces,
|
|
214
|
+
() => isCheckoutPieceMounted(piece),
|
|
215
|
+
noPieceOnServer
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<>
|
|
220
|
+
<div data-checkout-outlet={piece} ref={targetRef} />
|
|
221
|
+
{props.isDesigner && !inBodyZone && (
|
|
222
|
+
<DesignerPlaceholder
|
|
223
|
+
label={`${designerLabel} outlet only works inside the Checkout Body zone — the storefront ignores it here`}
|
|
224
|
+
/>
|
|
225
|
+
)}
|
|
226
|
+
{props.isDesigner && canClaim && !isWinner && (
|
|
227
|
+
<DesignerPlaceholder
|
|
228
|
+
label={`${designerLabel} — already placed in another outlet on this page`}
|
|
229
|
+
/>
|
|
230
|
+
)}
|
|
231
|
+
{props.isDesigner && canClaim && isWinner && !pieceMounted && (
|
|
232
|
+
<DesignerPlaceholder
|
|
233
|
+
label={`${designerLabel} renders here on the storefront`}
|
|
234
|
+
/>
|
|
235
|
+
)}
|
|
236
|
+
{props.isDesigner && canClaim && isWinner && designerNote && (
|
|
237
|
+
<DesignerPlaceholder label={designerNote} />
|
|
238
|
+
)}
|
|
239
|
+
</>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useSyncExternalStore } from 'react';
|
|
4
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
5
|
+
import { CheckoutOutletBlock } from './checkout-outlet-base';
|
|
6
|
+
import {
|
|
7
|
+
getCheckoutFlowVariantOverride,
|
|
8
|
+
noFlowVariantOnServer,
|
|
9
|
+
subscribeCheckoutPieces
|
|
10
|
+
} from '../chrome/checkout-pieces';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parts HOSTED by this outlet: stamped (`partAttrs`) by the code-owned
|
|
14
|
+
* multi-step step indicator that teleports here, never rendered by the
|
|
15
|
+
* outlet itself. The editor's descriptor conformance reads this literal, and
|
|
16
|
+
* `checkout-hosted-parts.test.ts` verifies every name is honestly stamped in
|
|
17
|
+
* the app's checkout views — extend both sides together or the tests fail.
|
|
18
|
+
*/
|
|
19
|
+
export const HOSTED_PARTS = [
|
|
20
|
+
'steps',
|
|
21
|
+
'step',
|
|
22
|
+
'step-number',
|
|
23
|
+
'step-label',
|
|
24
|
+
'step-icon'
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Outlet for the checkout steps indicator. Multi-step variant only: the
|
|
29
|
+
* one-page family (one-page, accordion, basket-and-checkout) never mounts the
|
|
30
|
+
* steps piece, so on those storefronts this outlet stays an empty div on live
|
|
31
|
+
* and shows its labeled band in the designer. That is by design — a synthetic
|
|
32
|
+
* steps indicator is never fabricated for variants that have no steps. When
|
|
33
|
+
* the composition's flow outlet prefers the one-page layout, the designer
|
|
34
|
+
* band says so instead of promising a render that will never happen.
|
|
35
|
+
*/
|
|
36
|
+
const CheckoutStepsOutletBlock = (props: BlockRendererProps) => {
|
|
37
|
+
const flowVariantOverride = useSyncExternalStore(
|
|
38
|
+
subscribeCheckoutPieces,
|
|
39
|
+
getCheckoutFlowVariantOverride,
|
|
40
|
+
noFlowVariantOnServer
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<CheckoutOutletBlock
|
|
45
|
+
{...props}
|
|
46
|
+
piece="steps"
|
|
47
|
+
designerLabel="Checkout steps indicator"
|
|
48
|
+
designerNote={
|
|
49
|
+
flowVariantOverride === 'one-page'
|
|
50
|
+
? 'Step indicator only renders with the Step by step checkout — the One page checkout draws its own section numbers'
|
|
51
|
+
: null
|
|
52
|
+
}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export default CheckoutStepsOutletBlock;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
5
|
+
import { CheckoutOutletBlock } from './checkout-outlet-base';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parts HOSTED by this outlet: stamped (`partAttrs`) by the code-owned
|
|
9
|
+
* summary (multi-step `Summary` or one-page desktop `OnePageSummary`) that
|
|
10
|
+
* teleports here, never rendered by the outlet itself. The editor's
|
|
11
|
+
* descriptor conformance reads this literal, and
|
|
12
|
+
* `checkout-hosted-parts.test.ts` verifies every name is honestly stamped in
|
|
13
|
+
* the app's checkout views — extend both sides together or the tests fail.
|
|
14
|
+
*/
|
|
15
|
+
export const HOSTED_PARTS = [
|
|
16
|
+
'summary',
|
|
17
|
+
'summary-header',
|
|
18
|
+
'summary-title',
|
|
19
|
+
'summary-item',
|
|
20
|
+
'summary-row',
|
|
21
|
+
'summary-row-label',
|
|
22
|
+
'summary-row-value',
|
|
23
|
+
'summary-total',
|
|
24
|
+
'summary-total-label',
|
|
25
|
+
'summary-total-value',
|
|
26
|
+
'summary-edit'
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Outlet for the checkout order summary — multi-step's `Summary` or the
|
|
31
|
+
* one-page family's `OnePageSummary`, whichever variant mounts. The mobile
|
|
32
|
+
* summary sheet is never wrapped as a piece (it is a fixed-position overlay
|
|
33
|
+
* that travels only as part of the flow), so it never lands here.
|
|
34
|
+
*/
|
|
35
|
+
const CheckoutSummaryOutletBlock = (props: BlockRendererProps) => (
|
|
36
|
+
<CheckoutOutletBlock
|
|
37
|
+
{...props}
|
|
38
|
+
piece="summary"
|
|
39
|
+
designerLabel="Checkout order summary"
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export default CheckoutSummaryOutletBlock;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useState,
|
|
7
|
+
useSyncExternalStore
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { createPortal } from 'react-dom';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
announceCheckoutPiece,
|
|
13
|
+
CheckoutOutletRegistration,
|
|
14
|
+
CheckoutPieceKey,
|
|
15
|
+
getCheckoutOutletClaim,
|
|
16
|
+
noClaimOnServer,
|
|
17
|
+
retractCheckoutPiece,
|
|
18
|
+
subscribeCheckoutPieces
|
|
19
|
+
} from './checkout-pieces';
|
|
20
|
+
import { useCheckoutPiecesEnabled } from './checkout-pieces-provider';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Wraps a real, code-owned checkout component (the flow, the steps indicator,
|
|
24
|
+
* the order summary) and teleports it — via `createPortal`, so the component
|
|
25
|
+
* keeps its React parents and its state — into a checkout outlet block when a
|
|
26
|
+
* composition placed one, or renders it in place otherwise.
|
|
27
|
+
*
|
|
28
|
+
* Behavior and its consequences, pinned:
|
|
29
|
+
*
|
|
30
|
+
* - SSR and the pre-latch render are in place, so hydration matches the server
|
|
31
|
+
* HTML; with no composition the wrapper contributes ZERO wrapper elements
|
|
32
|
+
* and zero attributes (bare fragments), keeping the uncomposed page
|
|
33
|
+
* byte-identical to a build without the bridge.
|
|
34
|
+
* - The pending-to-portal flip remounts the children ONCE, at the piece's own
|
|
35
|
+
* mount instant, when the subtree is stateless (page gates: spinner/null;
|
|
36
|
+
* variant pieces: freshly mounted) and no payment surface exists yet. After
|
|
37
|
+
* the latch, the render never flips between portal and fragment on live, so
|
|
38
|
+
* iframes born inside the flow never move.
|
|
39
|
+
* - The latch works pre-first-paint because the checkout layout mounts the
|
|
40
|
+
* `checkout-page-body` ChromeSlot as an EARLIER SIBLING of the flow: outlet
|
|
41
|
+
* `useLayoutEffect` registrations flush before this latch runs, and the
|
|
42
|
+
* latch's state update flushes synchronously before paint.
|
|
43
|
+
* - Late registration on live is IGNORED — an unclaimed latch is permanent
|
|
44
|
+
* until navigation, so a composition publish applies to checkout on the
|
|
45
|
+
* next full page load, never mid-session.
|
|
46
|
+
* - If the latched outlet disappears, the piece comes home (renders in place)
|
|
47
|
+
* rather than chasing another outlet: ordering must never become impossible,
|
|
48
|
+
* and a portaled flow hosting an iframe must never be re-targeted.
|
|
49
|
+
* - The `useSyncExternalStore` server snapshot is "unclaimed", so the piece
|
|
50
|
+
* never portals during SSR.
|
|
51
|
+
* - Outside `CheckoutPiecesProvider` the wrapper is inert and always renders
|
|
52
|
+
* its children in place — checkout components reused anywhere else can
|
|
53
|
+
* never be teleported.
|
|
54
|
+
*/
|
|
55
|
+
export function CheckoutPiece({
|
|
56
|
+
piece,
|
|
57
|
+
children
|
|
58
|
+
}: {
|
|
59
|
+
piece: CheckoutPieceKey;
|
|
60
|
+
children: React.ReactNode;
|
|
61
|
+
}) {
|
|
62
|
+
const enabled = useCheckoutPiecesEnabled();
|
|
63
|
+
|
|
64
|
+
// Rendering the bridge as an inner component (instead of returning early
|
|
65
|
+
// past its hooks) keeps hook order legal for React while guaranteeing that
|
|
66
|
+
// nothing else runs when the bridge is disabled.
|
|
67
|
+
if (!enabled) return <>{children}</>;
|
|
68
|
+
|
|
69
|
+
return <CheckoutPieceBridge piece={piece}>{children}</CheckoutPieceBridge>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function CheckoutPieceBridge({
|
|
73
|
+
piece,
|
|
74
|
+
children
|
|
75
|
+
}: {
|
|
76
|
+
piece: CheckoutPieceKey;
|
|
77
|
+
children: React.ReactNode;
|
|
78
|
+
}) {
|
|
79
|
+
const [isDesigner, setIsDesigner] = useState(false);
|
|
80
|
+
|
|
81
|
+
// Designer detection uses the editor-proven flag, NOT a framing check.
|
|
82
|
+
// `window.__themeEditorDesigner` is set only after a trusted
|
|
83
|
+
// SET_THEME_EDITOR_COOKIE arrives through the origin-guarded editor bridge
|
|
84
|
+
// (see theme-placeholder-client.tsx) — being framed is not the same as
|
|
85
|
+
// being edited. A production checkout page legitimately iframed (partner
|
|
86
|
+
// portal, kiosk shell) must keep live semantics: the one-shot latch and
|
|
87
|
+
// no claim-following, so a portaled flow hosting a payment iframe can
|
|
88
|
+
// never be re-targeted mid-3DS.
|
|
89
|
+
//
|
|
90
|
+
// The flag can arrive AFTER this mount (it rides a postMessage). Designer
|
|
91
|
+
// semantics only diverge from live when a claim appears or changes, and
|
|
92
|
+
// every claim change notifies the pieces store — so re-checking on store
|
|
93
|
+
// notifications picks the flag up before the difference can matter.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (typeof window === 'undefined') return;
|
|
96
|
+
|
|
97
|
+
if (window.__themeEditorDesigner === true) {
|
|
98
|
+
setIsDesigner(true);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return subscribeCheckoutPieces(() => {
|
|
103
|
+
if (window.__themeEditorDesigner === true) {
|
|
104
|
+
setIsDesigner(true);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
// Live subscription to the current first-wins claim for this piece.
|
|
110
|
+
const claim = useSyncExternalStore(
|
|
111
|
+
subscribeCheckoutPieces,
|
|
112
|
+
() => getCheckoutOutletClaim(piece),
|
|
113
|
+
noClaimOnServer
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// The live site's one-shot decision: 'pending' until the mount latch runs,
|
|
117
|
+
// then either the outlet claimed at that instant or null forever.
|
|
118
|
+
const [decision, setDecision] = useState<
|
|
119
|
+
CheckoutOutletRegistration | null | 'pending'
|
|
120
|
+
>('pending');
|
|
121
|
+
|
|
122
|
+
useLayoutEffect(() => {
|
|
123
|
+
announceCheckoutPiece(piece);
|
|
124
|
+
setDecision(getCheckoutOutletClaim(piece));
|
|
125
|
+
|
|
126
|
+
return () => retractCheckoutPiece(piece);
|
|
127
|
+
}, [piece]);
|
|
128
|
+
|
|
129
|
+
if (isDesigner) {
|
|
130
|
+
// Designer canvas: follow the live claim so the author sees edits
|
|
131
|
+
// immediately. Retargeting remounts the piece and resets checkout state —
|
|
132
|
+
// acceptable on the canvas only.
|
|
133
|
+
return claim ? createPortal(children, claim.node) : <>{children}</>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (decision === 'pending' || decision === null) return <>{children}</>;
|
|
137
|
+
|
|
138
|
+
// Live site, latched to an outlet: honor it only while that exact outlet is
|
|
139
|
+
// still alive. Never chase a different outlet (no retargeting — iframe/3DS
|
|
140
|
+
// safety), and if the latched outlet disappears, come home so ordering is
|
|
141
|
+
// never impossible.
|
|
142
|
+
const alive = claim !== null && claim.id === decision.id;
|
|
143
|
+
|
|
144
|
+
return alive ? createPortal(children, decision.node) : <>{children}</>;
|
|
145
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Opt-in gate for the checkout pieces bridge. The checkout layout wraps its
|
|
7
|
+
* subtree with this provider; a `CheckoutPiece` rendered OUTSIDE it always
|
|
8
|
+
* renders its children in place, as a bare fragment. That guarantee is the
|
|
9
|
+
* point: checkout components reused anywhere else in an app can never be
|
|
10
|
+
* teleported by a composition, because only the checkout layout enables the
|
|
11
|
+
* bridge.
|
|
12
|
+
*/
|
|
13
|
+
const CheckoutPiecesEnabledContext = createContext(false);
|
|
14
|
+
|
|
15
|
+
export function CheckoutPiecesProvider({
|
|
16
|
+
children
|
|
17
|
+
}: {
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<CheckoutPiecesEnabledContext.Provider value={true}>
|
|
22
|
+
{children}
|
|
23
|
+
</CheckoutPiecesEnabledContext.Provider>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const useCheckoutPiecesEnabled = () =>
|
|
28
|
+
useContext(CheckoutPiecesEnabledContext);
|