@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,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
- const placeholders = data?.theme?.placeholders;
70
- if (!Array.isArray(placeholders)) return;
71
-
72
- const placeholder = placeholders.find(
73
- (item: { slug?: string; id?: string }) =>
74
- item?.slug === slug || item?.id === slug
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
- readCompositionState,
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
- let hasComposition = false;
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
- try {
51
- // The generic is the widget's attributes bag, not the widget wrapper
52
- const data = await getWidgetData<{
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 NOT composable — the checkout flow being
7
- * the canonical case. Without this, hovering the area in the designer gives
8
- * no feedback at all, which reads as "broken" rather than "code-owned": every
9
- * other region either offers the takeover affordance or an empty-zone band.
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 a note explaining why there is no edit affordance here and
14
- * where the composable zones are. The overlay never intercepts the pointer,
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
- The {label} is code-owned and cannot be composed — use the zones
79
- above and below it
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
+ }