@akinon/pz-theme 2.0.83-rc.0 → 2.0.84-beta.0
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 +39 -33
- package/package.json +4 -4
- package/src/__tests__/auth-flow-block.test.tsx +211 -0
- package/src/__tests__/auth-flow-bus.test.ts +290 -0
- package/src/__tests__/auth-form-error-block.test.tsx +326 -0
- package/src/__tests__/auth-form-validation.test.tsx +204 -0
- package/src/__tests__/auth-step-title.test.tsx +202 -0
- package/src/__tests__/detached-submit-state.test.tsx +216 -0
- package/src/__tests__/forgot-password-composed.test.tsx +313 -0
- package/src/__tests__/otp-form-composed.test.tsx +487 -0
- package/src/__tests__/password-reset-step.test.tsx +269 -0
- package/src/__tests__/register-form-composed.test.tsx +739 -0
- package/src/__tests__/registry-parity.test.ts +9 -0
- package/src/auth/auth-field-errors.tsx +151 -0
- package/src/auth/auth-flow-bus.ts +415 -0
- package/src/auth/form-error-context.tsx +55 -0
- package/src/auth/form-submit-bus.ts +93 -0
- package/src/auth/otp-form-context.tsx +80 -0
- package/src/blocks/__tests__/button-block.test.tsx +55 -0
- package/src/blocks/__tests__/link-block.test.tsx +48 -30
- package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
- package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
- package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
- package/src/blocks/__tests__/text-block.test.tsx +37 -3
- package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
- package/src/blocks/auth-consent-block.tsx +214 -0
- package/src/blocks/auth-error-block.tsx +82 -0
- package/src/blocks/auth-field-block.tsx +270 -0
- package/src/blocks/auth-flow-block.tsx +251 -0
- package/src/blocks/auth-form-block.tsx +391 -47
- package/src/blocks/auth-step-block.tsx +81 -0
- package/src/blocks/auth-step-title-block.tsx +81 -0
- package/src/blocks/block-renderer-registry.tsx +18 -0
- package/src/blocks/button-block.tsx +91 -13
- package/src/blocks/forgot-password-block.tsx +318 -24
- package/src/blocks/link-block.tsx +22 -84
- package/src/blocks/otp-code-input-block.tsx +180 -0
- package/src/blocks/otp-form-block.tsx +1092 -0
- package/src/blocks/otp-phase-block.tsx +71 -0
- package/src/blocks/register-form-block.tsx +541 -74
- package/src/blocks/text-block.tsx +17 -40
- package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
- package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
- package/src/hooks/use-block-action.ts +87 -4
- package/src/hooks/use-overlay.ts +60 -9
- package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
- package/src/overlays/__tests__/modal-registry.test.ts +6 -1
- package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
- package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
- package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
- package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
- package/src/overlays/modal-preview-host.tsx +39 -2
- package/src/overlays/modal-registry.ts +344 -4
- package/src/overlays/overlay-surface.tsx +249 -19
- package/src/overlays/overlay-tokens.ts +94 -0
- package/src/overlays/theme-modal-mount.tsx +34 -4
- package/src/theme-block.tsx +6 -0
- package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
- package/src/utils/index.ts +107 -6
- package/src/utils/iterator-utils.ts +39 -2
- 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
|
+
);
|