@akinon/pz-theme 2.0.83-rc.0 → 2.0.83

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 (63) hide show
  1. package/CHANGELOG.md +31 -33
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-flow-block.test.tsx +211 -0
  4. package/src/__tests__/auth-flow-bus.test.ts +290 -0
  5. package/src/__tests__/auth-form-error-block.test.tsx +326 -0
  6. package/src/__tests__/auth-form-validation.test.tsx +204 -0
  7. package/src/__tests__/auth-step-title.test.tsx +202 -0
  8. package/src/__tests__/detached-submit-state.test.tsx +216 -0
  9. package/src/__tests__/forgot-password-composed.test.tsx +313 -0
  10. package/src/__tests__/otp-form-composed.test.tsx +487 -0
  11. package/src/__tests__/password-reset-step.test.tsx +269 -0
  12. package/src/__tests__/register-form-composed.test.tsx +739 -0
  13. package/src/__tests__/registry-parity.test.ts +9 -0
  14. package/src/auth/auth-field-errors.tsx +151 -0
  15. package/src/auth/auth-flow-bus.ts +415 -0
  16. package/src/auth/form-error-context.tsx +55 -0
  17. package/src/auth/form-submit-bus.ts +93 -0
  18. package/src/auth/otp-form-context.tsx +80 -0
  19. package/src/blocks/__tests__/button-block.test.tsx +55 -0
  20. package/src/blocks/__tests__/link-block.test.tsx +48 -30
  21. package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
  22. package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
  23. package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
  24. package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
  25. package/src/blocks/__tests__/text-block.test.tsx +37 -3
  26. package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
  27. package/src/blocks/auth-consent-block.tsx +214 -0
  28. package/src/blocks/auth-error-block.tsx +82 -0
  29. package/src/blocks/auth-field-block.tsx +270 -0
  30. package/src/blocks/auth-flow-block.tsx +251 -0
  31. package/src/blocks/auth-form-block.tsx +391 -47
  32. package/src/blocks/auth-step-block.tsx +81 -0
  33. package/src/blocks/auth-step-title-block.tsx +81 -0
  34. package/src/blocks/block-renderer-registry.tsx +18 -0
  35. package/src/blocks/button-block.tsx +91 -13
  36. package/src/blocks/forgot-password-block.tsx +318 -24
  37. package/src/blocks/link-block.tsx +22 -84
  38. package/src/blocks/otp-code-input-block.tsx +180 -0
  39. package/src/blocks/otp-form-block.tsx +1092 -0
  40. package/src/blocks/otp-phase-block.tsx +71 -0
  41. package/src/blocks/register-form-block.tsx +541 -74
  42. package/src/blocks/text-block.tsx +17 -40
  43. package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
  44. package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
  45. package/src/hooks/use-block-action.ts +87 -4
  46. package/src/hooks/use-overlay.ts +60 -9
  47. package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
  48. package/src/overlays/__tests__/modal-registry.test.ts +6 -1
  49. package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
  50. package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
  51. package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
  52. package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
  53. package/src/overlays/modal-preview-host.tsx +39 -2
  54. package/src/overlays/modal-registry.ts +344 -4
  55. package/src/overlays/overlay-surface.tsx +249 -19
  56. package/src/overlays/overlay-tokens.ts +94 -0
  57. package/src/overlays/theme-modal-mount.tsx +34 -4
  58. package/src/theme-block.tsx +6 -0
  59. package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
  60. package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
  61. package/src/utils/index.ts +107 -6
  62. package/src/utils/iterator-utils.ts +39 -2
  63. package/src/utils/part-styles.ts +4 -0
@@ -32,7 +32,13 @@ const EXPECTED_BLOCK_TYPES = [
32
32
  'address-list',
33
33
  'announcement-bar',
34
34
  'applied-filters',
35
+ 'auth-consent',
36
+ 'auth-error',
37
+ 'auth-flow',
38
+ 'auth-field',
35
39
  'auth-form',
40
+ 'auth-step',
41
+ 'auth-step-title',
36
42
  'basket-items',
37
43
  'basket-summary',
38
44
  'button',
@@ -78,6 +84,9 @@ const EXPECTED_BLOCK_TYPES = [
78
84
  'order-list',
79
85
  'order-success',
80
86
  'order-tracking',
87
+ 'otp-code-input',
88
+ 'otp-form',
89
+ 'otp-phase',
81
90
  'panel',
82
91
  'payment-badges',
83
92
  'product-attributes',
@@ -0,0 +1,151 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ /**
6
+ * What a composed auth form found wrong on its last submit, keyed by the name
7
+ * each control submits under.
8
+ *
9
+ * Native constraint validation used to do this job: `required` on a field and
10
+ * the browser draws its own bubble when the form is submitted — its own copy
11
+ * ("Please fill in this field"), its own typography, its own position, none of
12
+ * which a brand can touch, and gone the moment focus moves. The storefront's
13
+ * own auth pages have always drawn the message UNDER the field instead, in the
14
+ * brand's error colour, and left it there until the field was fixed.
15
+ *
16
+ * So the form carries `noValidate`, which turns the bubble off while leaving
17
+ * `validity` reporting exactly as before: the RULES stay where a designer set
18
+ * them (the field's Required toggle, `type="email"`) and only the presentation
19
+ * moves in here.
20
+ *
21
+ * Why a context rather than props: the fields of a composed form are child
22
+ * BLOCKS at any depth — a field inside a Group inside a Step — so the form has
23
+ * no reference to hand a message to. Each field reads its own entry instead.
24
+ */
25
+ export type AuthFieldErrors = Readonly<Record<string, string>>;
26
+
27
+ const NO_ERRORS: AuthFieldErrors = {};
28
+
29
+ const AuthFieldErrorsContext = React.createContext<AuthFieldErrors>(NO_ERRORS);
30
+
31
+ export const AuthFieldErrorsProvider = AuthFieldErrorsContext.Provider;
32
+
33
+ /** The message for one control name, '' while that field is fine. */
34
+ export const useAuthFieldError = (name: string): string => {
35
+ const errors = React.useContext(AuthFieldErrorsContext);
36
+
37
+ return errors[name] || '';
38
+ };
39
+
40
+ /** The copy a form shows for the two things a field can be wrong about. */
41
+ export interface AuthValidationMessages {
42
+ /** Left empty while the field is required. */
43
+ required: string;
44
+ /** Filled in, but not a value of the field's type — an e-mail with no @. */
45
+ invalid: string;
46
+ }
47
+
48
+ export interface AuthFieldValidation {
49
+ errors: AuthFieldErrors;
50
+ /**
51
+ * The first offending control, so the form can put the caret where the work
52
+ * is — the one thing the native bubble did well.
53
+ */
54
+ firstInvalid: HTMLElement | null;
55
+ }
56
+
57
+ type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
58
+
59
+ /**
60
+ * Whether a control is actually on screen for this breakpoint.
61
+ *
62
+ * `checkVisibility` answers this outright where it exists; the walk is the
63
+ * fallback for engines (and for jsdom) that do not have it yet. Only the two
64
+ * properties that REMOVE a control from the page are consulted — an element
65
+ * scrolled out of view, sized to nothing, or covered by another is still a
66
+ * control the shopper can be sent to.
67
+ */
68
+ const isVisibleControl = (control: FormControl): boolean => {
69
+ if (typeof control.checkVisibility === 'function') {
70
+ return control.checkVisibility();
71
+ }
72
+
73
+ let node: HTMLElement | null = control;
74
+
75
+ while (node) {
76
+ const style = window.getComputedStyle(node);
77
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
78
+ node = node.parentElement;
79
+ }
80
+
81
+ return true;
82
+ };
83
+
84
+ /**
85
+ * What is wrong with the form as it stands, read from the controls themselves.
86
+ *
87
+ * Deliberately DOM-driven rather than config-driven: a composed form's fields
88
+ * are blocks the designer arranged, and the constraints already live on the
89
+ * controls that got rendered. Reading `validity` keeps one source of truth and
90
+ * covers a field this theme knows nothing about — anything with a name inside
91
+ * the form is checked the same way the browser would have checked it.
92
+ */
93
+ export const collectAuthFieldErrors = (
94
+ form: HTMLFormElement,
95
+ messages: AuthValidationMessages
96
+ ): AuthFieldValidation => {
97
+ const errors: Record<string, string> = {};
98
+ let firstInvalid: HTMLElement | null = null;
99
+
100
+ const controls = Array.from(
101
+ form.querySelectorAll<FormControl>(
102
+ 'input[name], select[name], textarea[name]'
103
+ )
104
+ );
105
+
106
+ controls.forEach((control) => {
107
+ // `willValidate` is false for a disabled, hidden or readonly control —
108
+ // exactly the set the browser skips, so the two agree on what counts.
109
+ if (!control.willValidate || control.validity.valid) return;
110
+
111
+ // `hidden` above means `type="hidden"`, NOT a control the design has taken
112
+ // off this breakpoint. The editor's Visibility setting is CSS — it writes
113
+ // `display: none` onto the block wrapper and leaves the control in the
114
+ // form — and `willValidate` does not care about CSS. So a required field
115
+ // hidden on mobile refused every submit there: nothing was focusable,
116
+ // `noValidate` had already suppressed the browser's bubble, and the
117
+ // message this function returns was drawn inside the hidden wrapper. A
118
+ // field the shopper cannot see is not one they can be asked to fill in.
119
+ if (!isVisibleControl(control)) return;
120
+
121
+ // First one wins per name: a radio group, or a name a composition
122
+ // duplicated, is one question to the shopper rather than several.
123
+ if (!errors[control.name]) {
124
+ errors[control.name] = control.validity.valueMissing
125
+ ? messages.required
126
+ : messages.invalid;
127
+ }
128
+
129
+ if (!firstInvalid) firstInvalid = control;
130
+ });
131
+
132
+ return { errors, firstInvalid };
133
+ };
134
+
135
+ /**
136
+ * The same map with one field's message dropped — what a form does the moment
137
+ * that field is edited, so a message never outlives the mistake it describes.
138
+ * The identical object comes back when there was nothing to drop, which keeps
139
+ * a keystroke in a valid field from re-rendering the form.
140
+ */
141
+ export const clearAuthFieldError = (
142
+ errors: AuthFieldErrors,
143
+ name: string
144
+ ): AuthFieldErrors => {
145
+ if (!name || !errors[name]) return errors;
146
+
147
+ const next = { ...errors };
148
+ delete next[name];
149
+
150
+ return next;
151
+ };
@@ -0,0 +1,415 @@
1
+ 'use client';
2
+
3
+ import React, { useSyncExternalStore } from 'react';
4
+
5
+ /**
6
+ * Which step every auth flow on the page is showing, plus the one pending
7
+ * SMS-verification the page may be carrying.
8
+ *
9
+ * A step is changed by something that is not necessarily inside the flow — a
10
+ * header button that opens the login drawer straight on the register step, a
11
+ * banner CTA, the register form asking for its verification code. Those can
12
+ * live in DIFFERENT React roots: every placeholder mounts its own
13
+ * `ThemePlaceholderWrapper`, so header and drawer share no provider and no
14
+ * context. Module state is the one channel that crosses those roots — exactly
15
+ * the reasoning `overlay-bus.ts` already applies to composed modals, and this
16
+ * bus copies its shape deliberately (SSR guard, `useSyncExternalStore`,
17
+ * empty-key subscriptions that cost nothing).
18
+ *
19
+ * Two flows that share a `flowId` share a step on purpose: a desktop drawer
20
+ * and a mobile sheet composed as two blocks stay in sync instead of each
21
+ * keeping a private half of the journey.
22
+ *
23
+ * // trigger, anywhere on the page
24
+ * setAuthStep('auth', 'register');
25
+ *
26
+ * // the flow block
27
+ * const state = useAuthFlowState(flowId);
28
+ * const activeKey = state?.active ?? initialStep;
29
+ */
30
+
31
+ export interface AuthFlowState {
32
+ /** The step key the flow should be showing. */
33
+ active: string;
34
+ /** Previously active step keys, oldest first — what "back" walks. */
35
+ history: readonly string[];
36
+ }
37
+
38
+ /**
39
+ * The registration or phone-login a shopper started that now waits on an SMS
40
+ * code. Written by whichever block hit the OTP branch (the register form, the
41
+ * OTP form's own phone phase) and read by the `otp-form` block, which may sit
42
+ * in another step or another placeholder entirely.
43
+ */
44
+ export interface PendingOtp {
45
+ kind: 'login' | 'register';
46
+ /** Bare digits, as commerce expects them. */
47
+ phone?: string;
48
+ /** The next-auth provider the verification must re-submit through. */
49
+ providerId?: string;
50
+ /**
51
+ * For `register`: the full signIn payload (formType, locale, fields,
52
+ * consents) the code is appended to on verify — commerce answers the same
53
+ * endpoint, now with `code`, exactly as the brand's own register view does.
54
+ */
55
+ payload?: Record<string, unknown>;
56
+ /**
57
+ * Which journey the verification belongs to: the flow whose step holds the
58
+ * OTP form, or `''` for a page that stands a register form and an OTP form
59
+ * side by side without a flow around them.
60
+ *
61
+ * An `otp-form` claims only a record from its OWN scope. Without that, one
62
+ * page-wide slot meant every form on the page answered to it: a header's
63
+ * phone-login drawer, opened long after a sign-up was abandoned, would find
64
+ * a `register` record, skip its own phone half, and re-post someone's
65
+ * registration — password included — under a code they typed for something
66
+ * else.
67
+ */
68
+ scope?: string;
69
+ /**
70
+ * The step the handoff moved to, when a target was named. Navigating the
71
+ * flow anywhere else abandons the verification: a payload that outlives the
72
+ * step it belongs to is one nothing on the page is waiting for.
73
+ */
74
+ stepKey?: string;
75
+ }
76
+
77
+ /**
78
+ * The flow id every control falls back to when nothing narrower is known: a
79
+ * page composes one auth journey far more often than several, and asking every
80
+ * author to invent and retype an id for it would make the common case the
81
+ * fiddly one.
82
+ */
83
+ export const DEFAULT_AUTH_FLOW_ID = 'auth';
84
+
85
+ /**
86
+ * A journey cannot meaningfully be this deep; past it something is navigating
87
+ * in a loop. The history is capped rather than refused so "back" keeps
88
+ * working — the oldest entries fall off instead of the click dying.
89
+ */
90
+ const MAX_HISTORY = 32;
91
+
92
+ /**
93
+ * How long a parked verification stays meaningful.
94
+ *
95
+ * The commerce code expires long before this; the cap is here because the
96
+ * record carries a REGISTRATION until it is verified — a password among the
97
+ * fields — and module state lives as long as the tab does. On a page where
98
+ * nothing else can clear it (a register form standing on its own, with no flow
99
+ * to unmount and no OTP form to finish the journey) the payload would otherwise
100
+ * sit in memory for the rest of the visit.
101
+ */
102
+ const PENDING_OTP_TTL_MS = 15 * 60 * 1000;
103
+
104
+ let flowStates: ReadonlyMap<string, AuthFlowState> = new Map();
105
+ let pendingOtp: PendingOtp | null = null;
106
+ let pendingOtpTimer: ReturnType<typeof setTimeout> | null = null;
107
+
108
+ const listeners = new Set<() => void>();
109
+
110
+ const notify = () => {
111
+ listeners.forEach((listener) => listener());
112
+ };
113
+
114
+ export const subscribeToAuthFlows = (listener: () => void): (() => void) => {
115
+ listeners.add(listener);
116
+
117
+ return () => {
118
+ listeners.delete(listener);
119
+ };
120
+ };
121
+
122
+ /**
123
+ * Mutations are refused outside the browser. Module state on a server is per
124
+ * PROCESS, not per request — one visitor's step change would leak into the
125
+ * next visitor's render. Nothing legitimate calls these outside an event
126
+ * handler or an effect. (Same contract as `overlay-bus.ts`.)
127
+ */
128
+ const canMutate = () => typeof window !== 'undefined';
129
+
130
+ /**
131
+ * Drop the parked verification and the timer holding it. Declared here rather
132
+ * than beside the other pending helpers because the step mutations below abandon
133
+ * a verification the shopper has navigated away from, and a `const` does not
134
+ * hoist. Callers that also change flow state notify once, for both.
135
+ */
136
+ const forgetPendingOtp = ({ quiet = false }: { quiet?: boolean } = {}) => {
137
+ if (pendingOtpTimer) {
138
+ clearTimeout(pendingOtpTimer);
139
+ pendingOtpTimer = null;
140
+ }
141
+
142
+ if (pendingOtp === null) return;
143
+
144
+ pendingOtp = null;
145
+ if (!quiet) notify();
146
+ };
147
+
148
+ /**
149
+ * Whether a step change leaves behind the verification the page is carrying.
150
+ * Only a record that named a step can be abandoned this way — one parked
151
+ * without a target (the register form and the OTP form composed side by side,
152
+ * no flow between them) is not tied to any step and stays until it is finished,
153
+ * cleared, or times out.
154
+ */
155
+ const abandonsPendingOtp = (flowId: string, stepKey: string): boolean =>
156
+ pendingOtp !== null &&
157
+ (pendingOtp.scope || '') === flowId &&
158
+ Boolean(pendingOtp.stepKey) &&
159
+ pendingOtp.stepKey !== stepKey;
160
+
161
+ export const getAuthFlowState = (flowId: string): AuthFlowState | null =>
162
+ flowStates.get(flowId) ?? null;
163
+
164
+ export const setAuthStep = (flowId: string, stepKey: string): void => {
165
+ if (!canMutate() || !flowId || !stepKey) return;
166
+
167
+ const current = flowStates.get(flowId);
168
+
169
+ // Re-selecting the active step must not stack a phantom "back" entry.
170
+ if (current?.active === stepKey) return;
171
+
172
+ const history = current
173
+ ? [...current.history, current.active].slice(-MAX_HISTORY)
174
+ : [];
175
+
176
+ const next = new Map(flowStates);
177
+ next.set(flowId, { active: stepKey, history });
178
+ flowStates = next;
179
+
180
+ // Walking away from the step that holds the verification abandons it: a
181
+ // payload left armed is one the next OTP form to mount would answer for.
182
+ if (abandonsPendingOtp(flowId, stepKey)) forgetPendingOtp({ quiet: true });
183
+
184
+ notify();
185
+ };
186
+
187
+ /**
188
+ * Walk one step back. With no history left the flow is reset instead: the
189
+ * state disappears and the flow block falls back to its authored initial
190
+ * step, which is where the journey began.
191
+ */
192
+ export const goBackAuthStep = (flowId: string): void => {
193
+ if (!canMutate() || !flowId) return;
194
+
195
+ const current = flowStates.get(flowId);
196
+ if (!current) return;
197
+
198
+ const next = new Map(flowStates);
199
+
200
+ if (current.history.length === 0) {
201
+ next.delete(flowId);
202
+ // No history left means the flow is back at its authored initial step,
203
+ // which is never the step a handoff moved to.
204
+ if (abandonsPendingOtp(flowId, '')) forgetPendingOtp({ quiet: true });
205
+ } else {
206
+ const history = [...current.history];
207
+ const active = history.pop() as string;
208
+ next.set(flowId, { active, history });
209
+ if (abandonsPendingOtp(flowId, active)) forgetPendingOtp({ quiet: true });
210
+ }
211
+
212
+ flowStates = next;
213
+ notify();
214
+ };
215
+
216
+ /** Drop a flow's state so it renders its authored initial step again. */
217
+ export const resetAuthFlow = (flowId: string): void => {
218
+ if (!canMutate() || !flowStates.has(flowId)) return;
219
+
220
+ const next = new Map(flowStates);
221
+ next.delete(flowId);
222
+ flowStates = next;
223
+ notify();
224
+ };
225
+
226
+ /**
227
+ * How many flow blocks currently render each flow id, so a flow that leaves
228
+ * the page can clean up after itself without a breakpoint twin resetting it.
229
+ * A drawer's flow unmounts when the drawer closes (`ThemeModalMount` renders
230
+ * children only while open) — that is the moment a fresh-start flow forgets
231
+ * the abandoned journey. But a desktop and a mobile flow sharing an id are
232
+ * BOTH mounted (responsive hiding is CSS, not unmount), so only the last
233
+ * mount out may reset.
234
+ */
235
+ const mountCounts = new Map<string, number>();
236
+
237
+ export const registerAuthFlowMount = (
238
+ flowId: string,
239
+ { resetOnLastUnmount = false }: { resetOnLastUnmount?: boolean } = {}
240
+ ): (() => void) => {
241
+ if (!canMutate() || !flowId) return () => undefined;
242
+
243
+ mountCounts.set(flowId, (mountCounts.get(flowId) ?? 0) + 1);
244
+
245
+ // Releases its own registration exactly once — StrictMode double-invokes
246
+ // effect cleanups in development, same guard as overlay-bus's host counter.
247
+ let released = false;
248
+
249
+ return () => {
250
+ if (released) return;
251
+
252
+ released = true;
253
+ const remaining = Math.max(0, (mountCounts.get(flowId) ?? 1) - 1);
254
+
255
+ if (remaining === 0) {
256
+ mountCounts.delete(flowId);
257
+
258
+ if (resetOnLastUnmount) {
259
+ resetAuthFlow(flowId);
260
+ // A verification the shopper walked away from must not resurface in a
261
+ // later, unrelated login: the payload holds a password. Scoped to THIS
262
+ // flow — the reset above already is, and a page may carry a second
263
+ // flow, or an OTP form outside every flow, whose journey is still live.
264
+ clearPendingOtp(flowId);
265
+ }
266
+ } else {
267
+ mountCounts.set(flowId, remaining);
268
+ }
269
+ };
270
+ };
271
+
272
+ /**
273
+ * The step a flow is showing, and what it is called.
274
+ *
275
+ * A drawer that walks a shopper through several steps has ONE close button and
276
+ * ONE title row, but the title changes with the step. Authoring the title
277
+ * inside each step cannot produce that: the row is a sibling of the steps, so
278
+ * it renders once and has no way to know which step is up. The flow does know —
279
+ * it is the thing that decided — so it says so here, and a title block sitting
280
+ * anywhere inside the flow can read it.
281
+ *
282
+ * Context rather than the module bus above: the value is derived per render
283
+ * from the flow's own children (their keys and titles), not shared page state,
284
+ * and a flow renders its children in the same React tree.
285
+ */
286
+ export interface ActiveAuthStep {
287
+ key: string;
288
+ title: string;
289
+ }
290
+
291
+ export const ActiveAuthStepContext = React.createContext<ActiveAuthStep | null>(
292
+ null
293
+ );
294
+
295
+ /** Null outside a flow, which is how a title block knows to draw nothing. */
296
+ export const useActiveAuthStep = (): ActiveAuthStep | null =>
297
+ React.useContext(ActiveAuthStepContext);
298
+
299
+ export const getPendingOtp = (): PendingOtp | null => pendingOtp;
300
+
301
+ export const setPendingOtp = (next: PendingOtp): void => {
302
+ if (!canMutate()) return;
303
+
304
+ forgetPendingOtp({ quiet: true });
305
+ pendingOtp = next;
306
+ pendingOtpTimer = setTimeout(forgetPendingOtp, PENDING_OTP_TTL_MS);
307
+ notify();
308
+ };
309
+
310
+ /**
311
+ * Forget the parked verification.
312
+ *
313
+ * With a `scope` the clear speaks only for that journey — the flow cleanup
314
+ * below is per flow id, and the verification the page is carrying may belong to
315
+ * a different flow entirely, or to an OTP form standing outside every flow.
316
+ * Without one it is unconditional: that is what a shopper's own "use a
317
+ * different number" and a finished verification mean.
318
+ */
319
+ export const clearPendingOtp = (scope?: string): void => {
320
+ if (!canMutate() || pendingOtp === null) return;
321
+ if (scope !== undefined && (pendingOtp.scope || '') !== scope) return;
322
+
323
+ forgetPendingOtp();
324
+ };
325
+
326
+ /**
327
+ * Whether an OTP form standing in `flowId` (empty outside every flow) is the
328
+ * one this verification was parked for. Scopes must match rather than merely
329
+ * overlap: an unscoped record belongs to the page's own composition, and a
330
+ * form inside a drawer is not part of it.
331
+ */
332
+ export const ownsPendingOtp = (
333
+ pending: PendingOtp | null,
334
+ flowId: string
335
+ ): boolean => pending !== null && (pending.scope || '') === (flowId || '');
336
+
337
+ /**
338
+ * Nothing is navigated and nothing is pending during SSR — the server
339
+ * snapshot must always say so, or the serialized markup would differ from the
340
+ * client's page-load state and hydration would tear. (See `overlay-bus.ts`.)
341
+ */
342
+ const nullOnServer = () => null;
343
+
344
+ /** Subscription for a caller with no flow to watch — costs no listener. */
345
+ const neverNotifies = () => () => undefined;
346
+
347
+ const alwaysNull = () => null;
348
+
349
+ /** Pass an empty id for "nothing to watch" — it costs no subscription. */
350
+ export const useAuthFlowState = (flowId: string): AuthFlowState | null =>
351
+ useSyncExternalStore(
352
+ flowId ? subscribeToAuthFlows : neverNotifies,
353
+ flowId ? () => flowStates.get(flowId) ?? null : alwaysNull,
354
+ nullOnServer
355
+ );
356
+
357
+ export const usePendingOtp = (): PendingOtp | null =>
358
+ useSyncExternalStore(subscribeToAuthFlows, getPendingOtp, nullOnServer);
359
+
360
+ /**
361
+ * The flow a block sits inside, when it sits inside one. The flow block
362
+ * provides it around its children — those render through `ThemeBlock` in the
363
+ * same React tree, so context works there and spares the author retyping the
364
+ * flow id on every trigger. Cross-root triggers (a header button) have no
365
+ * provider and spell the flow out in their target instead.
366
+ */
367
+ export const AuthFlowIdContext = React.createContext<string>('');
368
+
369
+ export interface AuthStepTarget {
370
+ flowId: string;
371
+ stepKey: string;
372
+ }
373
+
374
+ /**
375
+ * What an authored step target means.
376
+ *
377
+ * Two spellings: `stepKey` alone acts on the nearest flow (context, then the
378
+ * default id), and `flowId:stepKey` names the flow outright — what a trigger
379
+ * outside every flow uses. An empty step is no target at all.
380
+ */
381
+ export const resolveAuthStepTarget = (
382
+ target: string,
383
+ contextFlowId = ''
384
+ ): AuthStepTarget | null => {
385
+ const trimmed = target.trim();
386
+ if (!trimmed) return null;
387
+
388
+ const separator = trimmed.indexOf(':');
389
+
390
+ if (separator >= 0) {
391
+ const flowId = trimmed.slice(0, separator).trim();
392
+ const stepKey = trimmed.slice(separator + 1).trim();
393
+ if (!stepKey) return null;
394
+
395
+ return {
396
+ flowId: flowId || contextFlowId || DEFAULT_AUTH_FLOW_ID,
397
+ stepKey
398
+ };
399
+ }
400
+
401
+ return {
402
+ flowId: contextFlowId || DEFAULT_AUTH_FLOW_ID,
403
+ stepKey: trimmed
404
+ };
405
+ };
406
+
407
+ /** Test-only: drop all state so cases cannot leak into each other. */
408
+ export const __resetAuthFlowBusForTests = (): void => {
409
+ flowStates = new Map();
410
+ if (pendingOtpTimer) clearTimeout(pendingOtpTimer);
411
+ pendingOtpTimer = null;
412
+ pendingOtp = null;
413
+ listeners.clear();
414
+ mountCounts.clear();
415
+ };
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import type { Block } from '../theme-block';
6
+
7
+ /**
8
+ * The one message a composed auth form is answering with right now.
9
+ *
10
+ * A form-level error has no field to sit under — it is about the submission,
11
+ * not about an input — so the form used to append it after everything it drew.
12
+ * In a composition that is the wrong place twice over: the CTA is one of the
13
+ * children, so the message landed BELOW the button that caused it, and the
14
+ * padding a composition puts on its own wrapper does not reach a node the form
15
+ * appends outside that wrapper, so the message also sat flush against the panel
16
+ * edge while every other line was inset.
17
+ *
18
+ * Neither is fixable from the form's side: it cannot know which child is the
19
+ * CTA, and it cannot know which wrapper carries the design's padding. What it
20
+ * does know is the message — so it publishes it here and an `auth-error` block
21
+ * draws it wherever the design says, inheriting that block's own spacing like
22
+ * any other. The form keeps its built-in node for the composition that has not
23
+ * placed one, so nothing goes silent.
24
+ *
25
+ * Both auth forms provide it: an OTP verification and an email login answer
26
+ * with the same kind of message and a composition should not care which one it
27
+ * is standing in.
28
+ */
29
+ export const AuthFormErrorContext = React.createContext<string>('');
30
+
31
+ export const AuthFormErrorProvider = AuthFormErrorContext.Provider;
32
+
33
+ /** The form's current message, or '' — outside a form and while it is fine. */
34
+ export const useAuthFormError = (): string =>
35
+ React.useContext(AuthFormErrorContext);
36
+
37
+ /**
38
+ * Whether the blocks a form is ABOUT to draw contain an error block.
39
+ *
40
+ * Asked of the rendered subset rather than of every child, because the OTP form
41
+ * draws one phase at a time: an error block authored only inside the code phase
42
+ * must not silence the built-in message while the phone phase is up, or a
43
+ * shopper whose number was refused would be told nothing at all.
44
+ */
45
+ export const containsAuthErrorBlock = (blocks: Block[] | undefined): boolean =>
46
+ (blocks || []).some(
47
+ (block) =>
48
+ // A hidden block draws nothing, so it cannot be the place the message
49
+ // goes. The forms filter their OWN children before asking, but the
50
+ // recursion did not — a Form Error tucked inside a Group and then hidden
51
+ // counted as authored, silenced the form's built-in message, and drew
52
+ // nothing itself. The shopper was told nothing at all.
53
+ !block.hidden &&
54
+ (block.type === 'auth-error' || containsAuthErrorBlock(block.blocks))
55
+ );