@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
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useContext, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { signIn } from 'next-auth/react';
|
|
6
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
7
|
+
import { useOtpLoginMutation } from '@akinon/next/data/client/user';
|
|
8
|
+
import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
|
|
9
|
+
import { readToggle } from './product-block-utils';
|
|
10
|
+
import { closeAllModals } from '../overlays/overlay-bus';
|
|
11
|
+
import {
|
|
12
|
+
AuthFlowIdContext,
|
|
13
|
+
clearPendingOtp,
|
|
14
|
+
ownsPendingOtp,
|
|
15
|
+
setPendingOtp,
|
|
16
|
+
usePendingOtp
|
|
17
|
+
} from '../auth/auth-flow-bus';
|
|
18
|
+
import {
|
|
19
|
+
AuthFieldErrors,
|
|
20
|
+
AuthFieldErrorsProvider,
|
|
21
|
+
clearAuthFieldError,
|
|
22
|
+
collectAuthFieldErrors
|
|
23
|
+
} from '../auth/auth-field-errors';
|
|
24
|
+
import {
|
|
25
|
+
AuthFormErrorProvider,
|
|
26
|
+
containsAuthErrorBlock
|
|
27
|
+
} from '../auth/form-error-context';
|
|
28
|
+
import {
|
|
29
|
+
clearFormSubmitState,
|
|
30
|
+
publishFormSubmitState
|
|
31
|
+
} from '../auth/form-submit-bus';
|
|
32
|
+
import { OtpFormApi, OtpFormProvider } from '../auth/otp-form-context';
|
|
33
|
+
import { renderComposedChildren } from './render-composed-children';
|
|
34
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* pz brands serialise an AuthError[] into next-auth's `error` string, but a
|
|
38
|
+
* plain string is also possible, so both shapes are handled. Kept local rather
|
|
39
|
+
* than imported from auth-form-block, for the same reason register-form keeps
|
|
40
|
+
* its own copy: exporting it would change a shipped module's public surface.
|
|
41
|
+
*/
|
|
42
|
+
const readAuthError = (error: string | null | undefined): string => {
|
|
43
|
+
if (!error) return '';
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(error);
|
|
47
|
+
if (!Array.isArray(parsed)) return String(error);
|
|
48
|
+
|
|
49
|
+
const messages: string[] = [];
|
|
50
|
+
|
|
51
|
+
parsed.forEach((entry) => {
|
|
52
|
+
const data = (entry as { data?: unknown })?.data;
|
|
53
|
+
|
|
54
|
+
if (Array.isArray(data)) {
|
|
55
|
+
data.forEach((item) => {
|
|
56
|
+
if (typeof item === 'string') {
|
|
57
|
+
messages.push(item);
|
|
58
|
+
} else if (item && typeof item === 'object') {
|
|
59
|
+
const value = (item as { value?: unknown }).value;
|
|
60
|
+
if (typeof value === 'string') messages.push(value);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
} else if (typeof data === 'string') {
|
|
64
|
+
messages.push(data);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return messages.join(' ') || String(error);
|
|
69
|
+
} catch {
|
|
70
|
+
return String(error);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Where the serialised AuthError[] actually lives depends on the next-auth
|
|
76
|
+
* major. v4 puts it into `error` itself; v5 keeps `error` at the generic
|
|
77
|
+
* "CredentialsSignin" enum name and moves the serialised array onto `code` —
|
|
78
|
+
* which is also what the brands' own views read. Preferring `code` shows the
|
|
79
|
+
* shopper the real commerce message on v5 storefronts instead of the enum.
|
|
80
|
+
*/
|
|
81
|
+
const signInErrorDetail = (
|
|
82
|
+
response: { error?: string | null; code?: string | null } | null | undefined
|
|
83
|
+
): string | null | undefined => response?.code || response?.error;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether commerce answered with another SMS-verification challenge — which is
|
|
87
|
+
* what a RESEND of a parked registration is supposed to get back, as opposed to
|
|
88
|
+
* the refusals (a rate limit, an expired attempt) that must reach the shopper.
|
|
89
|
+
* Kept local for the same reason `readAuthError` is: exporting register-form's
|
|
90
|
+
* copy would change a shipped module's public surface.
|
|
91
|
+
*/
|
|
92
|
+
const hasOtpChallenge = (error: string | null | undefined): boolean => {
|
|
93
|
+
if (!error) return false;
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(error);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
Array.isArray(parsed) &&
|
|
100
|
+
parsed.some((entry) => (entry as { type?: unknown })?.type === 'otp')
|
|
101
|
+
);
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The OTP endpoint answers a successful send with 2xx and an EMPTY body, which
|
|
109
|
+
* RTK Query rejects as a parse failure. Brands treat that rejection as the
|
|
110
|
+
* success it is, and so does this block.
|
|
111
|
+
*/
|
|
112
|
+
const isEmptyBodySuccess = (error: unknown): boolean => {
|
|
113
|
+
const shaped = error as { status?: unknown; originalStatus?: unknown };
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
shaped?.status === 'PARSING_ERROR' &&
|
|
117
|
+
typeof shaped.originalStatus === 'number' &&
|
|
118
|
+
shaped.originalStatus >= 200 &&
|
|
119
|
+
shaped.originalStatus < 300
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
type OtpPhase = 'phone' | 'code';
|
|
124
|
+
|
|
125
|
+
type PreviewPhase = 'auto' | OtpPhase;
|
|
126
|
+
|
|
127
|
+
const PREVIEW_PHASES: PreviewPhase[] = ['auto', 'phone', 'code'];
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Functional auth block: SMS verification as a step of its own.
|
|
131
|
+
*
|
|
132
|
+
* Two phases in one block. The PHONE phase collects a number and asks
|
|
133
|
+
* commerce for a code (`/users/otp-login/`) — that alone is the "log in with
|
|
134
|
+
* your phone" journey. The CODE phase renders the code boxes, a countdown, a
|
|
135
|
+
* resend control and the verify submit. Which sign-in the code completes
|
|
136
|
+
* comes from the auth bus: a `register` context re-submits the registration
|
|
137
|
+
* the register form parked there (same payload, now with `code`), anything
|
|
138
|
+
* else signs in through the OTP credentials path (`formType: 'otpLogin'`),
|
|
139
|
+
* exactly as brand storefronts do.
|
|
140
|
+
*
|
|
141
|
+
* Because the pending context is module state, the block works from anywhere:
|
|
142
|
+
* the step after a register form, a drawer of its own, or standalone with
|
|
143
|
+
* `collectPhone` on. Without a phone phase and with nothing pending there is
|
|
144
|
+
* nothing to verify, so on the storefront the block renders nothing at all.
|
|
145
|
+
*
|
|
146
|
+
* Properties: collectPhone, showLabels, phoneLabel, phonePlaceholder,
|
|
147
|
+
* phoneDescription, sendLabel, sendBusyLabel, sendErrorLabel,
|
|
148
|
+
* codeDescription, codeLength, timerSeconds, showCountdown, resendLabel,
|
|
149
|
+
* verifyLabel, verifyBusyLabel, changeLabel, providerId, otpFormType,
|
|
150
|
+
* callbackUrl, errorLabel, closeModalsOnSuccess, previewPhase.
|
|
151
|
+
* Parts: form, description, field, label, input, code, code-input, countdown,
|
|
152
|
+
* resend, change, error, submit.
|
|
153
|
+
*/
|
|
154
|
+
/** Whether a subtree holds the block the designer has selected. */
|
|
155
|
+
const containsBlockId = (
|
|
156
|
+
blocks: BlockRendererProps['block']['blocks'],
|
|
157
|
+
blockId: string
|
|
158
|
+
): boolean =>
|
|
159
|
+
(blocks || []).some(
|
|
160
|
+
(candidate) =>
|
|
161
|
+
candidate.id === blockId || containsBlockId(candidate.blocks, blockId)
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const OtpFormBlock = ({
|
|
165
|
+
block,
|
|
166
|
+
placeholderId,
|
|
167
|
+
sectionId,
|
|
168
|
+
selectedBlockId,
|
|
169
|
+
currentBreakpoint = 'desktop',
|
|
170
|
+
isDesigner = false
|
|
171
|
+
}: BlockRendererProps) => {
|
|
172
|
+
const prop = (key: string, fallback: string) =>
|
|
173
|
+
String(
|
|
174
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const collectPhone = readToggle(
|
|
178
|
+
block.properties?.collectPhone,
|
|
179
|
+
currentBreakpoint,
|
|
180
|
+
true
|
|
181
|
+
);
|
|
182
|
+
const showLabels = readToggle(
|
|
183
|
+
block.properties?.showLabels,
|
|
184
|
+
currentBreakpoint,
|
|
185
|
+
true
|
|
186
|
+
);
|
|
187
|
+
const phoneLabel = prop('phoneLabel', 'Phone');
|
|
188
|
+
const phonePlaceholder = prop('phonePlaceholder', '');
|
|
189
|
+
/**
|
|
190
|
+
* The copy for a number left empty or typed wrongly — the brand's own
|
|
191
|
+
* sentence in place of the browser's bubble.
|
|
192
|
+
*/
|
|
193
|
+
const requiredMessage = prop('requiredMessage', 'This field is required.');
|
|
194
|
+
const invalidMessage = prop('invalidMessage', 'Please check this field.');
|
|
195
|
+
const phoneDescription = prop('phoneDescription', '');
|
|
196
|
+
const sendLabel = prop('sendLabel', 'Send code');
|
|
197
|
+
// Empty on purpose: without a busy label the caption stays put while the
|
|
198
|
+
// request runs, matching every other auth block.
|
|
199
|
+
const sendBusyLabel = prop('sendBusyLabel', '');
|
|
200
|
+
const sendErrorLabel = prop(
|
|
201
|
+
'sendErrorLabel',
|
|
202
|
+
'Could not send the code. Please try again.'
|
|
203
|
+
);
|
|
204
|
+
const codeDescription = prop(
|
|
205
|
+
'codeDescription',
|
|
206
|
+
'Enter the verification code sent to {phone}.'
|
|
207
|
+
);
|
|
208
|
+
const rawCodeLength = Number(
|
|
209
|
+
getResponsiveValue(block.properties?.codeLength, currentBreakpoint, 6)
|
|
210
|
+
);
|
|
211
|
+
const codeLength =
|
|
212
|
+
Number.isFinite(rawCodeLength) && rawCodeLength >= 4 && rawCodeLength <= 8
|
|
213
|
+
? Math.floor(rawCodeLength)
|
|
214
|
+
: 6;
|
|
215
|
+
const rawTimer = Number(
|
|
216
|
+
getResponsiveValue(block.properties?.timerSeconds, currentBreakpoint, 60)
|
|
217
|
+
);
|
|
218
|
+
const timerSeconds =
|
|
219
|
+
Number.isFinite(rawTimer) && rawTimer >= 10 && rawTimer <= 600
|
|
220
|
+
? Math.floor(rawTimer)
|
|
221
|
+
: 60;
|
|
222
|
+
const showCountdown = readToggle(
|
|
223
|
+
block.properties?.showCountdown,
|
|
224
|
+
currentBreakpoint,
|
|
225
|
+
true
|
|
226
|
+
);
|
|
227
|
+
const resendLabel = prop('resendLabel', 'Resend code');
|
|
228
|
+
const verifyLabel = prop('verifyLabel', 'Verify');
|
|
229
|
+
const verifyBusyLabel = prop('verifyBusyLabel', '');
|
|
230
|
+
const changeLabel = prop('changeLabel', 'Use a different number');
|
|
231
|
+
const providerId = prop('providerId', 'default');
|
|
232
|
+
const otpFormType = prop('otpFormType', 'otpLogin');
|
|
233
|
+
const callbackUrl = prop('callbackUrl', '/');
|
|
234
|
+
const errorLabel = prop('errorLabel', 'Verification failed.');
|
|
235
|
+
/**
|
|
236
|
+
* Submitting a half-typed code used to be a silent no-op: the built-in verify
|
|
237
|
+
* button is disabled until the last box is filled, so nothing could reach it.
|
|
238
|
+
* A COMPOSED form's CTA is an ordinary Button block, which cannot disable
|
|
239
|
+
* itself — so the form has to say what is missing instead of doing nothing.
|
|
240
|
+
*/
|
|
241
|
+
/**
|
|
242
|
+
* Detached submit, the same pair the other auth forms carry: naming the form
|
|
243
|
+
* lets a Button anywhere on the page submit it through the native `form`
|
|
244
|
+
* attribute — which is how a drawer pins its CTA to the bottom bar.
|
|
245
|
+
*/
|
|
246
|
+
const formHtmlId = prop('formHtmlId', '').trim();
|
|
247
|
+
const codeIncompleteMessage = prop(
|
|
248
|
+
'codeIncompleteMessage',
|
|
249
|
+
'Enter the {length}-digit code we sent you.'
|
|
250
|
+
);
|
|
251
|
+
// Defaults on: the commonest home for this block is a drawer, and a drawer
|
|
252
|
+
// still covering the page the shopper just signed into reads as a hang.
|
|
253
|
+
// closeAllModals is a no-op when nothing is open, so pages pay nothing.
|
|
254
|
+
const closeModalsOnSuccess = readToggle(
|
|
255
|
+
block.properties?.closeModalsOnSuccess,
|
|
256
|
+
currentBreakpoint,
|
|
257
|
+
true
|
|
258
|
+
);
|
|
259
|
+
// The code phase is unreachable in the canvas without sending a real SMS,
|
|
260
|
+
// so the designer picks the phase to dress — the previewSuccess pattern.
|
|
261
|
+
const previewPhase = resolveOption<PreviewPhase>(
|
|
262
|
+
block.properties?.previewPhase,
|
|
263
|
+
currentBreakpoint,
|
|
264
|
+
'auto',
|
|
265
|
+
PREVIEW_PHASES
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const router = useRouter();
|
|
269
|
+
const { locale, defaultLocaleValue } = useLocalization();
|
|
270
|
+
const contextFlowId = useContext(AuthFlowIdContext);
|
|
271
|
+
/**
|
|
272
|
+
* The verification this form is allowed to finish.
|
|
273
|
+
*
|
|
274
|
+
* A page carries one parked record, and every OTP form used to answer to it —
|
|
275
|
+
* so a header's phone-login drawer, opened after a sign-up was abandoned
|
|
276
|
+
* elsewhere on the page, would open straight on the code screen and re-post
|
|
277
|
+
* that registration under whatever code the shopper typed. A form claims the
|
|
278
|
+
* record only when it belongs to the same journey; anything else is somebody
|
|
279
|
+
* else's, and this form starts where it always did.
|
|
280
|
+
*/
|
|
281
|
+
const parked = usePendingOtp();
|
|
282
|
+
const pending = ownsPendingOtp(parked, contextFlowId) ? parked : null;
|
|
283
|
+
const [otpLogin, { isLoading: isSending }] = useOtpLoginMutation();
|
|
284
|
+
|
|
285
|
+
const [phone, setPhone] = useState('');
|
|
286
|
+
const [code, setCode] = useState('');
|
|
287
|
+
const [error, setError] = useState('');
|
|
288
|
+
// Per FIELD, as opposed to `error`, which is what the backend answered.
|
|
289
|
+
const [fieldErrors, setFieldErrors] = useState<AuthFieldErrors>({});
|
|
290
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
291
|
+
// Its own flag rather than a fold into `isSubmitting`: a resend and a verify
|
|
292
|
+
// are different requests, and the countdown restart hangs off this one.
|
|
293
|
+
const [isResending, setIsResending] = useState(false);
|
|
294
|
+
// The author's phase choice: 'phone' after "use a different number", 'code'
|
|
295
|
+
// right after a send. Everything else derives from the pending context.
|
|
296
|
+
const [phaseOverride, setPhaseOverride] = useState<OtpPhase | null>(null);
|
|
297
|
+
const [secondsLeft, setSecondsLeft] = useState(timerSeconds);
|
|
298
|
+
// Bumped to restart the countdown after a send or resend.
|
|
299
|
+
const [timerEpoch, setTimerEpoch] = useState(0);
|
|
300
|
+
const codeInputsRef = useRef<Array<HTMLInputElement | null>>([]);
|
|
301
|
+
/**
|
|
302
|
+
* Whether the caret should walk itself to the next empty box.
|
|
303
|
+
*
|
|
304
|
+
* True while digits ARRIVE (typing into the row, a paste), false when the
|
|
305
|
+
* shopper goes back to correct one — moving the caret off a box the moment
|
|
306
|
+
* its digit is fixed would make the second correction land somewhere else.
|
|
307
|
+
*/
|
|
308
|
+
const advanceCaretRef = useRef(true);
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Composed mode: the form's pieces are CHILD blocks — an `otp-phase` per
|
|
312
|
+
* half of the journey, the code boxes as `otp-code-input`, the number as an
|
|
313
|
+
* ordinary Auth Field, the CTA as a Button. Same contract the auth form uses:
|
|
314
|
+
* children present means they draw the form, empty means the fixed layout
|
|
315
|
+
* renders exactly as it always did.
|
|
316
|
+
*/
|
|
317
|
+
const childBlocks = (block.blocks || [])
|
|
318
|
+
.filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
|
|
319
|
+
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
320
|
+
const isComposed = childBlocks.length > 0;
|
|
321
|
+
|
|
322
|
+
/** One flag for the three requests this block makes. */
|
|
323
|
+
const isBusy = isSending || isSubmitting || isResending;
|
|
324
|
+
|
|
325
|
+
const phase: OtpPhase = isDesigner
|
|
326
|
+
? previewPhase === 'auto'
|
|
327
|
+
? collectPhone
|
|
328
|
+
? 'phone'
|
|
329
|
+
: 'code'
|
|
330
|
+
: previewPhase
|
|
331
|
+
: phaseOverride ?? (pending ? 'code' : 'phone');
|
|
332
|
+
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
if (isDesigner || phase !== 'code') return undefined;
|
|
335
|
+
|
|
336
|
+
setSecondsLeft(timerSeconds);
|
|
337
|
+
const interval = setInterval(() => {
|
|
338
|
+
setSecondsLeft((current) => (current > 0 ? current - 1 : 0));
|
|
339
|
+
}, 1000);
|
|
340
|
+
|
|
341
|
+
return () => clearInterval(interval);
|
|
342
|
+
}, [isDesigner, phase, timerSeconds, timerEpoch]);
|
|
343
|
+
|
|
344
|
+
// Keep the caret on the next empty box while digits arrive — but leave it
|
|
345
|
+
// where the shopper put it when they are correcting one.
|
|
346
|
+
useEffect(() => {
|
|
347
|
+
if (isDesigner || phase !== 'code') return;
|
|
348
|
+
if (!advanceCaretRef.current) return;
|
|
349
|
+
|
|
350
|
+
const index = Math.min(code.length, codeLength - 1);
|
|
351
|
+
codeInputsRef.current[index]?.focus();
|
|
352
|
+
}, [isDesigner, phase, code, codeLength]);
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A detached CTA (a Button carrying this form's id) is outside this subtree
|
|
356
|
+
* and cannot see `isBusy` — so the form publishes it. See
|
|
357
|
+
* `auth/form-submit-bus.ts`.
|
|
358
|
+
*/
|
|
359
|
+
React.useEffect(() => {
|
|
360
|
+
if (!formHtmlId) return undefined;
|
|
361
|
+
|
|
362
|
+
publishFormSubmitState(formHtmlId, isBusy);
|
|
363
|
+
|
|
364
|
+
return () => clearFormSubmitState(formHtmlId);
|
|
365
|
+
}, [formHtmlId, isBusy]);
|
|
366
|
+
|
|
367
|
+
// Nothing to collect and nothing pending: there is no journey this block
|
|
368
|
+
// could complete, and a dead verify form is worse than no block at all.
|
|
369
|
+
if (!isDesigner && !collectPhone && !pending && phaseOverride === null) {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const canResend = secondsLeft <= 0;
|
|
374
|
+
/**
|
|
375
|
+
* Every box filled. Not the same as the code being `codeLength` long: a
|
|
376
|
+
* shopper who skips a box and fills a later one leaves a gap, which the code
|
|
377
|
+
* carries as a blank so the boxes after it stay where they were typed.
|
|
378
|
+
*/
|
|
379
|
+
const isCodeComplete = code.length === codeLength && !code.includes(' ');
|
|
380
|
+
const displayPhone = pending?.phone || phone.replace(/[^\d]/g, '');
|
|
381
|
+
const verifyProvider = pending?.providerId || providerId;
|
|
382
|
+
|
|
383
|
+
const startCodePhase = () => {
|
|
384
|
+
setCode('');
|
|
385
|
+
setPhaseOverride('code');
|
|
386
|
+
setTimerEpoch((epoch) => epoch + 1);
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const sendCode = async (digits: string): Promise<boolean> => {
|
|
390
|
+
try {
|
|
391
|
+
await otpLogin({ phone: digits }).unwrap();
|
|
392
|
+
return true;
|
|
393
|
+
} catch (sendError) {
|
|
394
|
+
if (isEmptyBodySuccess(sendError)) return true;
|
|
395
|
+
|
|
396
|
+
setError(sendErrorLabel);
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* A message describes what the field WAS: it goes as soon as it is edited.
|
|
403
|
+
* Both events, because a keystroke raises `input` while autofill raises
|
|
404
|
+
* `change`.
|
|
405
|
+
*/
|
|
406
|
+
const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
|
|
407
|
+
const target = event.target as { name?: string } | null;
|
|
408
|
+
|
|
409
|
+
setFieldErrors((current) =>
|
|
410
|
+
clearAuthFieldError(current, target?.name || '')
|
|
411
|
+
);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const submitPhone = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
415
|
+
event.preventDefault();
|
|
416
|
+
setError('');
|
|
417
|
+
|
|
418
|
+
// Submitting inside the designer would text a real shopper.
|
|
419
|
+
if (isDesigner || isSending) return;
|
|
420
|
+
|
|
421
|
+
// The form carries `noValidate`, so an empty number used to leave the
|
|
422
|
+
// browser's bubble here and now leaves our own message under the field.
|
|
423
|
+
// See `auth-field-errors.tsx`.
|
|
424
|
+
const { errors: nextFieldErrors, firstInvalid } = collectAuthFieldErrors(
|
|
425
|
+
event.currentTarget,
|
|
426
|
+
{ required: requiredMessage, invalid: invalidMessage }
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
setFieldErrors(nextFieldErrors);
|
|
430
|
+
|
|
431
|
+
if (firstInvalid) {
|
|
432
|
+
firstInvalid.focus();
|
|
433
|
+
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Composed: the number lives in a child Auth Field, so it is read off the
|
|
438
|
+
// form the same way an auth form reads its composed fields.
|
|
439
|
+
const typedPhone = isComposed
|
|
440
|
+
? String(new FormData(event.currentTarget).get('phone') ?? '')
|
|
441
|
+
: phone;
|
|
442
|
+
const digits = typedPhone.replace(/[^\d]/g, '');
|
|
443
|
+
|
|
444
|
+
// A number made only of the mask characters a brand renders around the
|
|
445
|
+
// input passes `required` and still has nothing to text.
|
|
446
|
+
if (!digits) {
|
|
447
|
+
setFieldErrors({ phone: invalidMessage });
|
|
448
|
+
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Kept so the code phase can name the number before `pending` carries it.
|
|
453
|
+
if (isComposed) setPhone(typedPhone);
|
|
454
|
+
|
|
455
|
+
if (await sendCode(digits)) {
|
|
456
|
+
setPendingOtp({
|
|
457
|
+
kind: 'login',
|
|
458
|
+
phone: digits,
|
|
459
|
+
providerId,
|
|
460
|
+
// Scoped to the journey this form stands in, so a verification started
|
|
461
|
+
// here is not picked up by an unrelated OTP form elsewhere.
|
|
462
|
+
scope: contextFlowId
|
|
463
|
+
});
|
|
464
|
+
startCodePhase();
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const submitCode = async (event: React.FormEvent) => {
|
|
469
|
+
event.preventDefault();
|
|
470
|
+
setError('');
|
|
471
|
+
|
|
472
|
+
if (isDesigner || isSubmitting) return;
|
|
473
|
+
|
|
474
|
+
if (!isCodeComplete) {
|
|
475
|
+
// The built-in button is disabled until the code is complete, so this is
|
|
476
|
+
// reachable only from a composed CTA — where silence would read as a
|
|
477
|
+
// dead button.
|
|
478
|
+
setError(codeIncompleteMessage.replace('{length}', String(codeLength)));
|
|
479
|
+
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const isRegisterVerification =
|
|
484
|
+
pending?.kind === 'register' && Boolean(pending.payload);
|
|
485
|
+
|
|
486
|
+
const payload: Record<string, unknown> = isRegisterVerification
|
|
487
|
+
? { ...(pending?.payload ?? {}), code }
|
|
488
|
+
: {
|
|
489
|
+
formType: otpFormType,
|
|
490
|
+
// The credentials provider throws when the locale is missing, so an
|
|
491
|
+
// explicit value is always sent.
|
|
492
|
+
locale: locale || defaultLocaleValue || '',
|
|
493
|
+
phone: displayPhone,
|
|
494
|
+
code
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
setIsSubmitting(true);
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
const response = await signIn(verifyProvider, {
|
|
501
|
+
// After the payload, for the reason register-form spells out: a key
|
|
502
|
+
// travelling in the body must not decide whether next-auth answers.
|
|
503
|
+
...payload,
|
|
504
|
+
redirect: false,
|
|
505
|
+
callbackUrl
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
if (!response || response.error) {
|
|
509
|
+
setError(readAuthError(signInErrorDetail(response)) || errorLabel);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
clearPendingOtp(contextFlowId);
|
|
514
|
+
if (closeModalsOnSuccess) closeAllModals();
|
|
515
|
+
router.refresh();
|
|
516
|
+
router.push(response.url || callbackUrl);
|
|
517
|
+
} catch {
|
|
518
|
+
setError(errorLabel);
|
|
519
|
+
} finally {
|
|
520
|
+
setIsSubmitting(false);
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const resend = async () => {
|
|
525
|
+
// `isBusy` as well as the countdown: the countdown only restarts once the
|
|
526
|
+
// request has ANSWERED, so between the click and the response the control
|
|
527
|
+
// still reads as available — a second click there texts the shopper twice,
|
|
528
|
+
// which is billed twice and which some backends count as abuse. Every other
|
|
529
|
+
// request-firing block in the package guards the same way.
|
|
530
|
+
if (isDesigner || !canResend || isBusy) return;
|
|
531
|
+
|
|
532
|
+
setError('');
|
|
533
|
+
setIsResending(true);
|
|
534
|
+
|
|
535
|
+
try {
|
|
536
|
+
if (pending?.kind === 'register' && pending.payload) {
|
|
537
|
+
// Re-submitting the parked registration makes commerce text a fresh
|
|
538
|
+
// code; the answer is another `otp` challenge, which is expected.
|
|
539
|
+
const response = await signIn(verifyProvider, {
|
|
540
|
+
...pending.payload,
|
|
541
|
+
resend: true,
|
|
542
|
+
redirect: false,
|
|
543
|
+
callbackUrl
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// `signIn` RESOLVES on a refusal rather than throwing, so the response
|
|
547
|
+
// has to be read: a rate limit or an expired attempt is not the `otp`
|
|
548
|
+
// challenge a resend expects, and restarting the countdown over it
|
|
549
|
+
// would leave the shopper waiting out a lockout for an SMS that was
|
|
550
|
+
// never sent. (Same shape `submitCode` reads.)
|
|
551
|
+
const detail = signInErrorDetail(response);
|
|
552
|
+
|
|
553
|
+
if (response?.error && !hasOtpChallenge(detail)) {
|
|
554
|
+
setError(readAuthError(detail) || sendErrorLabel);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
setCode('');
|
|
559
|
+
setTimerEpoch((epoch) => epoch + 1);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (displayPhone && (await sendCode(displayPhone))) {
|
|
564
|
+
setCode('');
|
|
565
|
+
setTimerEpoch((epoch) => epoch + 1);
|
|
566
|
+
}
|
|
567
|
+
} catch {
|
|
568
|
+
setError(sendErrorLabel);
|
|
569
|
+
} finally {
|
|
570
|
+
setIsResending(false);
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* A registration in flight has nowhere else to live: `pending.payload` is the
|
|
576
|
+
* ONLY copy of what the shopper filled in, so forgetting it to collect a new
|
|
577
|
+
* number would throw the sign-up away. The fixed layout has always hidden the
|
|
578
|
+
* control for that reason; a composed one puts it on a Button the author
|
|
579
|
+
* places, so the refusal belongs on the method rather than on the markup.
|
|
580
|
+
*/
|
|
581
|
+
const canChangeNumber = collectPhone && pending?.kind !== 'register';
|
|
582
|
+
|
|
583
|
+
const changeNumber = () => {
|
|
584
|
+
if (isDesigner || !canChangeNumber) return;
|
|
585
|
+
|
|
586
|
+
clearPendingOtp();
|
|
587
|
+
setCode('');
|
|
588
|
+
setError('');
|
|
589
|
+
setPhaseOverride('phone');
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const appendDigits = (digits: string) => {
|
|
593
|
+
if (!digits) return;
|
|
594
|
+
|
|
595
|
+
advanceCaretRef.current = true;
|
|
596
|
+
setCode((current) => (current + digits).slice(0, codeLength));
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Write one box.
|
|
601
|
+
*
|
|
602
|
+
* Everything used to go through `appendDigits`, which put digits at the END
|
|
603
|
+
* of the code whatever box they were typed in. Correcting a wrong digit was
|
|
604
|
+
* therefore impossible: on a full code the append was capped away and the
|
|
605
|
+
* keystroke did nothing at all, and on a half-typed one the box's OWN digit
|
|
606
|
+
* came back with the new one (the input is controlled, so its value is both)
|
|
607
|
+
* and both landed at the end — "123" fixed at the first box became "12319".
|
|
608
|
+
*
|
|
609
|
+
* The row already knows which box was typed in; it just had no way to say so.
|
|
610
|
+
*/
|
|
611
|
+
const setDigitAt = (index: number, digit: string) => {
|
|
612
|
+
if (index < 0 || index >= codeLength) return;
|
|
613
|
+
|
|
614
|
+
// A shopper typing into a box they have already filled means the LAST
|
|
615
|
+
// character they pressed, not the one that was sitting there.
|
|
616
|
+
const next = digit.slice(-1);
|
|
617
|
+
if (!next) return;
|
|
618
|
+
|
|
619
|
+
advanceCaretRef.current = false;
|
|
620
|
+
setCode((current) => {
|
|
621
|
+
const boxes = current.padEnd(codeLength, ' ').split('');
|
|
622
|
+
boxes[index] = next;
|
|
623
|
+
|
|
624
|
+
// Trailing blanks are what "not typed yet" looks like; a gap in the
|
|
625
|
+
// middle is a box the shopper skipped and can still come back to.
|
|
626
|
+
return boxes.join('').replace(/ +$/, '');
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// The caret still walks forward when the row is being filled in order,
|
|
630
|
+
// which is what makes typing a code feel like one field.
|
|
631
|
+
if (index === code.length && index < codeLength - 1) {
|
|
632
|
+
advanceCaretRef.current = true;
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
/** Empty one box — what Backspace means in the box it was pressed in. */
|
|
637
|
+
const clearDigitAt = (index: number) => {
|
|
638
|
+
if (index < 0 || index >= codeLength) return;
|
|
639
|
+
|
|
640
|
+
advanceCaretRef.current = false;
|
|
641
|
+
setCode((current) => {
|
|
642
|
+
if (index >= current.length) return current;
|
|
643
|
+
|
|
644
|
+
const boxes = current.split('');
|
|
645
|
+
boxes[index] = ' ';
|
|
646
|
+
|
|
647
|
+
return boxes.join('').replace(/ +$/, '');
|
|
648
|
+
});
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const inputStyle: React.CSSProperties = {
|
|
652
|
+
width: '100%',
|
|
653
|
+
border: '1px solid var(--otp-form-border, #d1d5db)',
|
|
654
|
+
background: 'transparent',
|
|
655
|
+
color: 'inherit',
|
|
656
|
+
padding: '10px 12px',
|
|
657
|
+
outline: 'none'
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
const submitStyle = (busy: boolean): React.CSSProperties => ({
|
|
661
|
+
padding: '12px 20px',
|
|
662
|
+
background: 'var(--otp-form-cta-bg, #111827)',
|
|
663
|
+
color: 'var(--otp-form-cta-color, #ffffff)',
|
|
664
|
+
border: 'none',
|
|
665
|
+
cursor: busy ? 'not-allowed' : 'pointer',
|
|
666
|
+
opacity: busy ? 0.7 : 1
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const errorNode = error ? (
|
|
670
|
+
<div data-otp-form-error="" {...partAttrs('error')} role="alert">
|
|
671
|
+
{error}
|
|
672
|
+
</div>
|
|
673
|
+
) : null;
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Which children this phase draws.
|
|
677
|
+
*
|
|
678
|
+
* A child that is not a phase is shared chrome — a logo, a title, a support
|
|
679
|
+
* link — and shows in both halves. A phase shows when it IS the phase, and
|
|
680
|
+
* additionally in the canvas while it holds the selected block, so the
|
|
681
|
+
* designer can dress the half they are not previewing. Same rule the auth
|
|
682
|
+
* flow applies to its steps.
|
|
683
|
+
*/
|
|
684
|
+
const showsInPhase = (childBlock: BlockRendererProps['block']): boolean => {
|
|
685
|
+
if (childBlock.type !== 'otp-phase') return true;
|
|
686
|
+
|
|
687
|
+
if (
|
|
688
|
+
isDesigner &&
|
|
689
|
+
selectedBlockId &&
|
|
690
|
+
(childBlock.id === selectedBlockId ||
|
|
691
|
+
containsBlockId(childBlock.blocks, selectedBlockId))
|
|
692
|
+
) {
|
|
693
|
+
return true;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return (
|
|
697
|
+
String(
|
|
698
|
+
getResponsiveValue(
|
|
699
|
+
childBlock.properties?.phaseKey,
|
|
700
|
+
currentBreakpoint,
|
|
701
|
+
''
|
|
702
|
+
)
|
|
703
|
+
).trim() === phase
|
|
704
|
+
);
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The children, with the six editor callbacks each one needs to be movable,
|
|
709
|
+
* duplicable, hideable, deletable and renamable on the canvas.
|
|
710
|
+
*
|
|
711
|
+
* Through the package's own `renderComposedChildren` rather than a private
|
|
712
|
+
* copy of it. The copies each posted their messages to `'*'`, which
|
|
713
|
+
* `editor-bridge.ts` exists to prevent — it addresses the editor's real
|
|
714
|
+
* origin, so an editor command never reaches whatever other page happens to
|
|
715
|
+
* be embedding the storefront — and every copy was a place a new message
|
|
716
|
+
* field had to be remembered.
|
|
717
|
+
*/
|
|
718
|
+
const renderChildren = () =>
|
|
719
|
+
renderComposedChildren({
|
|
720
|
+
blocks: childBlocks.filter(showsInPhase),
|
|
721
|
+
placeholderId,
|
|
722
|
+
sectionId,
|
|
723
|
+
isDesigner,
|
|
724
|
+
selectedBlockId,
|
|
725
|
+
currentBreakpoint
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
if (isComposed) {
|
|
729
|
+
/**
|
|
730
|
+
* What the children can reach: the code the form verifies, the countdown
|
|
731
|
+
* they draw, and the two controls that are actions rather than markup.
|
|
732
|
+
* See `auth/otp-form-context.tsx` for why a context and not props.
|
|
733
|
+
*/
|
|
734
|
+
const api: OtpFormApi = {
|
|
735
|
+
phase,
|
|
736
|
+
codeLength,
|
|
737
|
+
code,
|
|
738
|
+
appendDigits,
|
|
739
|
+
setDigitAt,
|
|
740
|
+
clearDigitAt,
|
|
741
|
+
removeLastDigit: () => {
|
|
742
|
+
advanceCaretRef.current = true;
|
|
743
|
+
setCode((current) => current.replace(/ +$/, '').slice(0, -1));
|
|
744
|
+
},
|
|
745
|
+
registerCodeInput: (index, element) => {
|
|
746
|
+
codeInputsRef.current[index] = element;
|
|
747
|
+
},
|
|
748
|
+
secondsLeft,
|
|
749
|
+
timerSeconds,
|
|
750
|
+
canResend,
|
|
751
|
+
resend,
|
|
752
|
+
changeNumber,
|
|
753
|
+
canChangeNumber,
|
|
754
|
+
displayPhone,
|
|
755
|
+
isBusy,
|
|
756
|
+
isDesigner
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Asked of the children this phase actually draws, not of all of them: an
|
|
761
|
+
* error block authored inside the code phase must not silence the built-in
|
|
762
|
+
* message while the phone phase is up. See `auth/form-error-context.tsx`.
|
|
763
|
+
*/
|
|
764
|
+
const errorIsAuthored = containsAuthErrorBlock(
|
|
765
|
+
childBlocks.filter(showsInPhase)
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
return (
|
|
769
|
+
// Three providers, three audiences: the OTP api is what the code boxes
|
|
770
|
+
// and the resend read, the field errors are what an Auth Field child
|
|
771
|
+
// draws under itself — the phone number here is an ordinary field and
|
|
772
|
+
// gets its message the same way a login form's does — and the form-level
|
|
773
|
+
// message is what a Form Error block draws wherever the design put it.
|
|
774
|
+
<OtpFormProvider value={api}>
|
|
775
|
+
<AuthFormErrorProvider value={error}>
|
|
776
|
+
<AuthFieldErrorsProvider value={fieldErrors}>
|
|
777
|
+
<form
|
|
778
|
+
id={formHtmlId || undefined}
|
|
779
|
+
data-otp-form=""
|
|
780
|
+
// Fills its block wrapper; see the note in auth-step-block.tsx.
|
|
781
|
+
className="w-full"
|
|
782
|
+
data-otp-form-phase={phase}
|
|
783
|
+
{...partAttrs('form')}
|
|
784
|
+
// One form for both halves: the phase decides which submit runs, so a
|
|
785
|
+
// composed CTA is an ordinary submit Button in either phase.
|
|
786
|
+
onSubmit={phase === 'phone' ? submitPhone : submitCode}
|
|
787
|
+
noValidate
|
|
788
|
+
onInput={handleInput}
|
|
789
|
+
onChange={handleInput}
|
|
790
|
+
style={{
|
|
791
|
+
display: 'flex',
|
|
792
|
+
flexDirection: 'column',
|
|
793
|
+
gap: 'var(--otp-form-gap, 12px)'
|
|
794
|
+
}}
|
|
795
|
+
>
|
|
796
|
+
{renderChildren()}
|
|
797
|
+
|
|
798
|
+
{/* The fallback for a composition that has not placed a Form Error
|
|
799
|
+
block: appended after every child, which is below the composed
|
|
800
|
+
CTA and outside the padding the design put on its own wrapper.
|
|
801
|
+
That is the whole reason the block exists. */}
|
|
802
|
+
{!errorIsAuthored && errorNode}
|
|
803
|
+
</form>
|
|
804
|
+
</AuthFieldErrorsProvider>
|
|
805
|
+
</AuthFormErrorProvider>
|
|
806
|
+
</OtpFormProvider>
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (phase === 'phone') {
|
|
811
|
+
return (
|
|
812
|
+
<form
|
|
813
|
+
id={formHtmlId || undefined}
|
|
814
|
+
data-otp-form=""
|
|
815
|
+
// Fills its block wrapper; see the note in auth-step-block.tsx.
|
|
816
|
+
className="w-full"
|
|
817
|
+
data-otp-form-phase="phone"
|
|
818
|
+
{...partAttrs('form')}
|
|
819
|
+
onSubmit={submitPhone}
|
|
820
|
+
// Our own message under the field instead of the browser's bubble over
|
|
821
|
+
// it; `required` below is still what decides.
|
|
822
|
+
noValidate
|
|
823
|
+
onInput={handleInput}
|
|
824
|
+
onChange={handleInput}
|
|
825
|
+
style={{
|
|
826
|
+
display: 'flex',
|
|
827
|
+
flexDirection: 'column',
|
|
828
|
+
gap: 'var(--otp-form-gap, 12px)'
|
|
829
|
+
}}
|
|
830
|
+
>
|
|
831
|
+
{phoneDescription && (
|
|
832
|
+
<div data-otp-form-description="" {...partAttrs('description')}>
|
|
833
|
+
{phoneDescription}
|
|
834
|
+
</div>
|
|
835
|
+
)}
|
|
836
|
+
|
|
837
|
+
<label
|
|
838
|
+
data-otp-form-field="phone"
|
|
839
|
+
{...partAttrs('field')}
|
|
840
|
+
style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
|
|
841
|
+
>
|
|
842
|
+
{/* Hiding the caption must not cost the input its accessible name,
|
|
843
|
+
so the label text moves onto aria-label instead. */}
|
|
844
|
+
{showLabels && <span {...partAttrs('label')}>{phoneLabel}</span>}
|
|
845
|
+
<input
|
|
846
|
+
type="tel"
|
|
847
|
+
name="phone"
|
|
848
|
+
value={phone}
|
|
849
|
+
onChange={(event) => setPhone(event.target.value)}
|
|
850
|
+
autoComplete="tel"
|
|
851
|
+
inputMode="tel"
|
|
852
|
+
placeholder={phonePlaceholder || undefined}
|
|
853
|
+
aria-label={showLabels ? undefined : phoneLabel}
|
|
854
|
+
aria-invalid={fieldErrors.phone ? true : undefined}
|
|
855
|
+
required
|
|
856
|
+
{...partAttrs('input')}
|
|
857
|
+
style={{
|
|
858
|
+
...inputStyle,
|
|
859
|
+
...(fieldErrors.phone
|
|
860
|
+
? { borderColor: 'var(--otp-form-border-invalid, #dc2626)' }
|
|
861
|
+
: null)
|
|
862
|
+
}}
|
|
863
|
+
/>
|
|
864
|
+
{fieldErrors.phone && (
|
|
865
|
+
<span
|
|
866
|
+
data-otp-form-field-error="phone"
|
|
867
|
+
{...partAttrs('error')}
|
|
868
|
+
role="alert"
|
|
869
|
+
style={{
|
|
870
|
+
color: 'var(--otp-form-error-color, #dc2626)',
|
|
871
|
+
fontSize: '0.875em'
|
|
872
|
+
}}
|
|
873
|
+
>
|
|
874
|
+
{fieldErrors.phone}
|
|
875
|
+
</span>
|
|
876
|
+
)}
|
|
877
|
+
</label>
|
|
878
|
+
|
|
879
|
+
{errorNode}
|
|
880
|
+
|
|
881
|
+
<button
|
|
882
|
+
type="submit"
|
|
883
|
+
data-otp-form-submit="send"
|
|
884
|
+
{...partAttrs('submit', { loading: isSending, disabled: isSending })}
|
|
885
|
+
disabled={isSending}
|
|
886
|
+
aria-busy={isSending}
|
|
887
|
+
style={submitStyle(isSending)}
|
|
888
|
+
>
|
|
889
|
+
{isSending && sendBusyLabel ? sendBusyLabel : sendLabel}
|
|
890
|
+
</button>
|
|
891
|
+
</form>
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const displayedSeconds = isDesigner ? timerSeconds : secondsLeft;
|
|
896
|
+
const countdownRatio =
|
|
897
|
+
timerSeconds > 0 ? Math.max(0, displayedSeconds / timerSeconds) : 0;
|
|
898
|
+
const ringCircumference = 2 * Math.PI * 16;
|
|
899
|
+
|
|
900
|
+
const countdown =
|
|
901
|
+
showCountdown && (isDesigner || !canResend) ? (
|
|
902
|
+
<span
|
|
903
|
+
data-otp-form-countdown=""
|
|
904
|
+
{...partAttrs('countdown')}
|
|
905
|
+
aria-hidden="true"
|
|
906
|
+
style={{ display: 'inline-flex', flexShrink: 0 }}
|
|
907
|
+
>
|
|
908
|
+
<svg width="40" height="40" viewBox="0 0 40 40" role="presentation">
|
|
909
|
+
<circle
|
|
910
|
+
cx="20"
|
|
911
|
+
cy="20"
|
|
912
|
+
r="16"
|
|
913
|
+
fill="none"
|
|
914
|
+
stroke="var(--otp-form-ring-track, #e5e7eb)"
|
|
915
|
+
strokeWidth="3"
|
|
916
|
+
/>
|
|
917
|
+
<circle
|
|
918
|
+
cx="20"
|
|
919
|
+
cy="20"
|
|
920
|
+
r="16"
|
|
921
|
+
fill="none"
|
|
922
|
+
stroke="var(--otp-form-ring, currentColor)"
|
|
923
|
+
strokeWidth="3"
|
|
924
|
+
strokeLinecap="round"
|
|
925
|
+
strokeDasharray={ringCircumference}
|
|
926
|
+
strokeDashoffset={ringCircumference * (1 - countdownRatio)}
|
|
927
|
+
transform="rotate(-90 20 20)"
|
|
928
|
+
/>
|
|
929
|
+
<text
|
|
930
|
+
x="20"
|
|
931
|
+
y="20"
|
|
932
|
+
textAnchor="middle"
|
|
933
|
+
dominantBaseline="central"
|
|
934
|
+
fontSize="12"
|
|
935
|
+
fill="currentColor"
|
|
936
|
+
>
|
|
937
|
+
{displayedSeconds}
|
|
938
|
+
</text>
|
|
939
|
+
</svg>
|
|
940
|
+
</span>
|
|
941
|
+
) : null;
|
|
942
|
+
|
|
943
|
+
const showChangeNumber =
|
|
944
|
+
collectPhone && (isDesigner || pending?.kind !== 'register');
|
|
945
|
+
|
|
946
|
+
return (
|
|
947
|
+
<form
|
|
948
|
+
id={formHtmlId || undefined}
|
|
949
|
+
data-otp-form=""
|
|
950
|
+
// Fills its block wrapper; see the note in auth-step-block.tsx.
|
|
951
|
+
className="w-full"
|
|
952
|
+
data-otp-form-phase="code"
|
|
953
|
+
{...partAttrs('form')}
|
|
954
|
+
onSubmit={submitCode}
|
|
955
|
+
style={{
|
|
956
|
+
display: 'flex',
|
|
957
|
+
flexDirection: 'column',
|
|
958
|
+
gap: 'var(--otp-form-gap, 12px)'
|
|
959
|
+
}}
|
|
960
|
+
>
|
|
961
|
+
{codeDescription && (
|
|
962
|
+
<div data-otp-form-description="" {...partAttrs('description')}>
|
|
963
|
+
{codeDescription.replace('{phone}', displayPhone)}
|
|
964
|
+
</div>
|
|
965
|
+
)}
|
|
966
|
+
|
|
967
|
+
<div
|
|
968
|
+
data-otp-form-code=""
|
|
969
|
+
{...partAttrs('code')}
|
|
970
|
+
style={{
|
|
971
|
+
display: 'flex',
|
|
972
|
+
alignItems: 'center',
|
|
973
|
+
gap: 'var(--otp-form-code-gap, 8px)'
|
|
974
|
+
}}
|
|
975
|
+
>
|
|
976
|
+
{countdown}
|
|
977
|
+
|
|
978
|
+
{Array.from({ length: codeLength }, (_, index) => (
|
|
979
|
+
<input
|
|
980
|
+
key={index}
|
|
981
|
+
ref={(element) => {
|
|
982
|
+
codeInputsRef.current[index] = element;
|
|
983
|
+
}}
|
|
984
|
+
type="text"
|
|
985
|
+
inputMode="numeric"
|
|
986
|
+
autoComplete={index === 0 ? 'one-time-code' : 'off'}
|
|
987
|
+
aria-label={`${index + 1} / ${codeLength}`}
|
|
988
|
+
// A blank stands for a box the shopper skipped; it draws empty.
|
|
989
|
+
value={(code[index] ?? '').trim()}
|
|
990
|
+
onChange={(event) =>
|
|
991
|
+
setDigitAt(index, event.target.value.replace(/\D/g, ''))
|
|
992
|
+
}
|
|
993
|
+
onKeyDown={(event) => {
|
|
994
|
+
if (event.key === 'Backspace') {
|
|
995
|
+
event.preventDefault();
|
|
996
|
+
|
|
997
|
+
// Empty the box it was pressed in, then step back — what a row
|
|
998
|
+
// of boxes does everywhere else. Deleting the LAST digit
|
|
999
|
+
// instead meant a shopper correcting the second box watched the
|
|
1000
|
+
// sixth one disappear.
|
|
1001
|
+
if (code[index] && code[index] !== ' ') {
|
|
1002
|
+
clearDigitAt(index);
|
|
1003
|
+
} else if (index > 0) {
|
|
1004
|
+
clearDigitAt(index - 1);
|
|
1005
|
+
codeInputsRef.current[index - 1]?.focus();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}}
|
|
1009
|
+
onPaste={(event) => {
|
|
1010
|
+
event.preventDefault();
|
|
1011
|
+
appendDigits(
|
|
1012
|
+
event.clipboardData.getData('text').replace(/\D/g, '')
|
|
1013
|
+
);
|
|
1014
|
+
}}
|
|
1015
|
+
{...partAttrs('code-input')}
|
|
1016
|
+
style={{
|
|
1017
|
+
width: '40px',
|
|
1018
|
+
height: '48px',
|
|
1019
|
+
textAlign: 'center',
|
|
1020
|
+
fontSize: '18px',
|
|
1021
|
+
border: '1px solid var(--otp-form-border, #d1d5db)',
|
|
1022
|
+
background: 'transparent',
|
|
1023
|
+
color: 'inherit',
|
|
1024
|
+
outline: 'none'
|
|
1025
|
+
}}
|
|
1026
|
+
/>
|
|
1027
|
+
))}
|
|
1028
|
+
|
|
1029
|
+
<button
|
|
1030
|
+
type="button"
|
|
1031
|
+
data-otp-form-resend=""
|
|
1032
|
+
{...partAttrs('resend', { disabled: !isDesigner && !canResend })}
|
|
1033
|
+
onClick={resend}
|
|
1034
|
+
disabled={!isDesigner && !canResend}
|
|
1035
|
+
style={{
|
|
1036
|
+
padding: 0,
|
|
1037
|
+
margin: 0,
|
|
1038
|
+
border: 'none',
|
|
1039
|
+
background: 'transparent',
|
|
1040
|
+
color: 'inherit',
|
|
1041
|
+
font: 'inherit',
|
|
1042
|
+
textDecoration: 'underline',
|
|
1043
|
+
cursor: !isDesigner && !canResend ? 'not-allowed' : 'pointer',
|
|
1044
|
+
opacity: !isDesigner && !canResend ? 0.5 : 1
|
|
1045
|
+
}}
|
|
1046
|
+
>
|
|
1047
|
+
{resendLabel}
|
|
1048
|
+
</button>
|
|
1049
|
+
</div>
|
|
1050
|
+
|
|
1051
|
+
{errorNode}
|
|
1052
|
+
|
|
1053
|
+
<button
|
|
1054
|
+
type="submit"
|
|
1055
|
+
data-otp-form-submit="verify"
|
|
1056
|
+
{...partAttrs('submit', {
|
|
1057
|
+
loading: isSubmitting,
|
|
1058
|
+
disabled: isSubmitting || (!isDesigner && !isCodeComplete)
|
|
1059
|
+
})}
|
|
1060
|
+
disabled={isSubmitting || (!isDesigner && !isCodeComplete)}
|
|
1061
|
+
aria-busy={isSubmitting}
|
|
1062
|
+
style={submitStyle(isSubmitting)}
|
|
1063
|
+
>
|
|
1064
|
+
{isSubmitting && verifyBusyLabel ? verifyBusyLabel : verifyLabel}
|
|
1065
|
+
</button>
|
|
1066
|
+
|
|
1067
|
+
{showChangeNumber && (
|
|
1068
|
+
<button
|
|
1069
|
+
type="button"
|
|
1070
|
+
data-otp-form-change=""
|
|
1071
|
+
{...partAttrs('change')}
|
|
1072
|
+
onClick={changeNumber}
|
|
1073
|
+
style={{
|
|
1074
|
+
alignSelf: 'flex-start',
|
|
1075
|
+
padding: 0,
|
|
1076
|
+
margin: 0,
|
|
1077
|
+
border: 'none',
|
|
1078
|
+
background: 'transparent',
|
|
1079
|
+
color: 'inherit',
|
|
1080
|
+
font: 'inherit',
|
|
1081
|
+
textDecoration: 'underline',
|
|
1082
|
+
cursor: 'pointer'
|
|
1083
|
+
}}
|
|
1084
|
+
>
|
|
1085
|
+
{changeLabel}
|
|
1086
|
+
</button>
|
|
1087
|
+
)}
|
|
1088
|
+
</form>
|
|
1089
|
+
);
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
export default OtpFormBlock;
|