@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +39 -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
@@ -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;