@akinon/pz-theme 2.0.43-rc.0 → 2.0.44

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +14 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/checkout-pieces.test.tsx +420 -0
  4. package/src/__tests__/checkout-slot-coverage.test.ts +181 -0
  5. package/src/__tests__/chrome-empty-zone.test.tsx +3 -3
  6. package/src/__tests__/code-owned-region.test.tsx +30 -6
  7. package/src/__tests__/composable-region.test.tsx +481 -0
  8. package/src/__tests__/part-styles.test.ts +24 -4
  9. package/src/__tests__/registry-parity.test.ts +3 -0
  10. package/src/__tests__/route-slug.test.ts +11 -0
  11. package/src/blocks/__tests__/checkout-hosted-parts.test.ts +203 -0
  12. package/src/blocks/__tests__/checkout-outlet-blocks.test.tsx +623 -0
  13. package/src/blocks/block-renderer-registry.tsx +6 -0
  14. package/src/blocks/checkout-flow-outlet-block.tsx +104 -0
  15. package/src/blocks/checkout-outlet-base.tsx +241 -0
  16. package/src/blocks/checkout-steps-outlet-block.tsx +57 -0
  17. package/src/blocks/checkout-summary-outlet-block.tsx +43 -0
  18. package/src/chrome/checkout-piece.tsx +145 -0
  19. package/src/chrome/checkout-pieces-provider.tsx +28 -0
  20. package/src/chrome/checkout-pieces.ts +173 -0
  21. package/src/chrome/chrome-fallback.tsx +8 -26
  22. package/src/chrome/chrome-slot.tsx +25 -26
  23. package/src/chrome/code-owned-region.tsx +21 -9
  24. package/src/chrome/composable-region-client.tsx +228 -0
  25. package/src/chrome/composable-region.tsx +58 -0
  26. package/src/chrome/composition-source.ts +34 -0
  27. package/src/chrome/composition-state.ts +49 -0
  28. package/src/chrome/live-checkout-page-context.tsx +3 -2
  29. package/src/chrome/placeholder-slugs.ts +14 -4
  30. package/src/chrome/route-slug.ts +6 -3
  31. package/src/hooks/use-designer-features.tsx +41 -10
  32. package/src/index.ts +6 -0
  33. package/src/utils/__tests__/checkout-safety.test.ts +374 -0
  34. package/src/utils/checkout-safety.ts +177 -0
  35. package/src/utils/index.ts +47 -8
  36. package/src/utils/part-styles.ts +4 -0
  37. 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);