@akinon/pz-theme 2.0.94 → 2.0.95-beta.1

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 (60) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-captcha.test.tsx +261 -0
  4. package/src/__tests__/auth-commerce-events.test.tsx +5 -1
  5. package/src/__tests__/auth-destination.test.tsx +348 -0
  6. package/src/__tests__/auth-error-reading.test.ts +232 -0
  7. package/src/__tests__/auth-flow-scope.test.tsx +162 -0
  8. package/src/__tests__/auth-form-error-block.test.tsx +2 -0
  9. package/src/__tests__/auth-form-success-block.test.tsx +429 -0
  10. package/src/__tests__/auth-form-validation.test.tsx +2 -0
  11. package/src/__tests__/auth-slot-coverage.test.ts +9 -1
  12. package/src/__tests__/auth-step-title.test.tsx +2 -0
  13. package/src/__tests__/block-action-audience.test.ts +154 -0
  14. package/src/__tests__/detached-submit-state.test.tsx +80 -2
  15. package/src/__tests__/forgot-password-composed.test.tsx +2 -0
  16. package/src/__tests__/form-state-composition.test.tsx +484 -0
  17. package/src/__tests__/modal-login-close.test.tsx +366 -0
  18. package/src/__tests__/otp-form-composed.test.tsx +2 -0
  19. package/src/__tests__/password-reset-step.test.tsx +2 -0
  20. package/src/__tests__/register-form-composed.test.tsx +253 -4
  21. package/src/__tests__/registry-parity.test.ts +12 -0
  22. package/src/__tests__/reset-password-composed.test.tsx +385 -0
  23. package/src/auth/auth-captcha.tsx +125 -0
  24. package/src/auth/auth-destination.ts +168 -0
  25. package/src/auth/auth-field-errors.tsx +31 -0
  26. package/src/auth/form-state-context.tsx +170 -0
  27. package/src/auth/form-success-context.tsx +56 -0
  28. package/src/auth/read-auth-error.ts +332 -0
  29. package/src/blocks/account-button-block.tsx +25 -0
  30. package/src/blocks/add-to-cart-block.tsx +15 -0
  31. package/src/blocks/auth-field-block.tsx +13 -3
  32. package/src/blocks/auth-flow-block.tsx +45 -3
  33. package/src/blocks/auth-form-block.tsx +337 -54
  34. package/src/blocks/auth-success-block.tsx +85 -0
  35. package/src/blocks/block-renderer-registry.tsx +8 -0
  36. package/src/blocks/button-block.tsx +43 -13
  37. package/src/blocks/captcha-block.tsx +61 -0
  38. package/src/blocks/change-password-form-block.tsx +15 -0
  39. package/src/blocks/forgot-password-block.tsx +132 -9
  40. package/src/blocks/form-state-block.tsx +84 -0
  41. package/src/blocks/newsletter-form-block.tsx +16 -0
  42. package/src/blocks/otp-form-block.tsx +161 -67
  43. package/src/blocks/profile-form-block.tsx +13 -0
  44. package/src/blocks/register-form-block.tsx +362 -110
  45. package/src/blocks/reset-password-block.tsx +717 -0
  46. package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
  47. package/src/chrome/placeholder-slugs.ts +3 -0
  48. package/src/chrome/route-slug.ts +11 -0
  49. package/src/index.ts +10 -0
  50. package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
  51. package/src/overlays/__tests__/modal-registry.test.ts +5 -1
  52. package/src/overlays/modal-chrome-css.ts +8 -1
  53. package/src/overlays/modal-host-context.tsx +66 -0
  54. package/src/overlays/modal-preview-host.tsx +7 -2
  55. package/src/overlays/modal-registry.ts +38 -1
  56. package/src/overlays/overlay-loading.tsx +144 -0
  57. package/src/overlays/overlay-surface.tsx +23 -0
  58. package/src/overlays/overlay-tokens.ts +12 -0
  59. package/src/overlays/theme-modal-mount.tsx +85 -6
  60. package/src/utils/visibility-rules.ts +114 -0
@@ -1,22 +1,32 @@
1
1
  'use client';
2
2
 
3
- import React, { useContext, useState } from 'react';
4
- import { useRouter } from 'next/navigation';
5
- import { signIn } from 'next-auth/react';
3
+ import React, { useContext, useRef, useState } from 'react';
4
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
5
+ import { signIn, useSession } from 'next-auth/react';
6
6
  import { useLocalization } from '@akinon/next/hooks';
7
- import { getResponsiveValue, partAttrs } from '../utils';
7
+ import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
8
8
  import { readToggle } from './product-block-utils';
9
9
  import { closeAllModals } from '../overlays/overlay-bus';
10
+ import {
11
+ useHostModalCloser,
12
+ useModalHostId
13
+ } from '../overlays/modal-host-context';
10
14
  import {
11
15
  AuthFieldErrors,
12
16
  AuthFieldErrorsProvider,
13
17
  clearAuthFieldError,
14
- collectAuthFieldErrors
18
+ collectAuthFieldErrors,
19
+ focusFirstAuthFieldError
15
20
  } from '../auth/auth-field-errors';
16
21
  import {
17
22
  AuthFormErrorProvider,
18
23
  containsAuthErrorBlock
19
24
  } from '../auth/form-error-context';
25
+ import {
26
+ AuthFormStateProvider,
27
+ AuthFormStateValue,
28
+ showsInFormState
29
+ } from '../auth/form-state-context';
20
30
  import {
21
31
  AUTH_CONSENT_NAMES,
22
32
  AuthConsentHostProvider
@@ -32,60 +42,29 @@ import {
32
42
  setAuthStep,
33
43
  setPendingOtp
34
44
  } from '../auth/auth-flow-bus';
45
+ import {
46
+ authFormMessage,
47
+ readAuthError,
48
+ splitByKnownFields
49
+ } from '../auth/read-auth-error';
50
+ import {
51
+ AUTH_AFTER_SIGN_IN_MODES,
52
+ AuthAfterSignIn,
53
+ resolveAuthDestination,
54
+ resolveSignedInDestination
55
+ } from '../auth/auth-destination';
56
+ import {
57
+ WHEN_SIGNED_IN_MODES,
58
+ WhenSignedIn
59
+ } from './auth-form-block';
60
+ import {
61
+ AuthCaptchaApi,
62
+ AuthCaptchaHost,
63
+ AuthCaptchaSlot,
64
+ containsCaptchaBlock
65
+ } from '../auth/auth-captcha';
35
66
  import { BlockRendererProps } from './block-renderer-registry';
36
67
 
37
- /**
38
- * pz brands serialise an AuthError[] into next-auth's `error` string, but a
39
- * plain string is also possible, so both shapes are handled. Kept local rather
40
- * than imported from auth-form-block: that block owns its own copy and making
41
- * it an export would change a shipped module's public surface.
42
- */
43
- const readAuthError = (
44
- error: string | null | undefined,
45
- otpMessage: string
46
- ): string => {
47
- if (!error) return '';
48
-
49
- try {
50
- const parsed = JSON.parse(error);
51
- if (!Array.isArray(parsed)) return String(error);
52
-
53
- const messages: string[] = [];
54
-
55
- parsed.forEach((entry) => {
56
- const type = (entry as { type?: unknown })?.type;
57
- const data = (entry as { data?: unknown })?.data;
58
-
59
- // The OTP branch carries no message: superstep answers a valid
60
- // registration with an `otp` error and expects the storefront to run an
61
- // SMS-verification step. When no `otpStepTarget` is set the challenge
62
- // lands here, so the shopper is at least told what happened instead of
63
- // seeing a silent failure.
64
- if (type === 'otp') {
65
- messages.push(otpMessage);
66
- return;
67
- }
68
-
69
- if (Array.isArray(data)) {
70
- data.forEach((item) => {
71
- if (typeof item === 'string') {
72
- messages.push(item);
73
- } else if (item && typeof item === 'object') {
74
- const value = (item as { value?: unknown }).value;
75
- if (typeof value === 'string') messages.push(value);
76
- }
77
- });
78
- } else if (typeof data === 'string') {
79
- messages.push(data);
80
- }
81
- });
82
-
83
- return messages.join(' ') || String(error);
84
- } catch {
85
- return String(error);
86
- }
87
- };
88
-
89
68
  /**
90
69
  * Names a composed field must not take, because the form and next-auth already
91
70
  * mean something by them.
@@ -109,27 +88,6 @@ const RESERVED_SUBMIT_KEYS = new Set([
109
88
  'json'
110
89
  ]);
111
90
 
112
- /**
113
- * Whether commerce answered a valid registration with an SMS-verification
114
- * challenge. Detected separately from the message extraction because the
115
- * challenge is a HANDOFF, not a failure: the pending payload moves to the
116
- * auth bus and an `otp-form` block finishes the sign-up.
117
- */
118
- const hasOtpChallenge = (error: string | null | undefined): boolean => {
119
- if (!error) return false;
120
-
121
- try {
122
- const parsed = JSON.parse(error);
123
-
124
- return (
125
- Array.isArray(parsed) &&
126
- parsed.some((entry) => (entry as { type?: unknown })?.type === 'otp')
127
- );
128
- } catch {
129
- return false;
130
- }
131
- };
132
-
133
91
  /**
134
92
  * Where the serialised AuthError[] actually lives depends on the next-auth
135
93
  * major. v4 puts it into `error` itself; v5 keeps `error` at the generic
@@ -147,11 +105,27 @@ const signInErrorDetail = (
147
105
 
148
106
  /**
149
107
  * The commerce registration endpoint is reached through next-auth's credentials
150
- * provider, exactly like the brand's own register view: the provider id is
151
- * `default` and the url segment is selected by `formType`.
108
+ * provider, exactly like the brand's own register view: a provider id and a
109
+ * `formType` that selects the url segment. Both are authored properties now —
110
+ * see `providerId` / `formType` in the renderer — because the compiled-in
111
+ * `registration` is not a key the core url map has.
152
112
  */
153
- const PROVIDER_ID = 'default';
154
- const FORM_TYPE = 'registration';
113
+ const DEFAULT_PROVIDER_ID = 'default';
114
+ const DEFAULT_FORM_TYPE = 'register';
115
+
116
+ /**
117
+ * The per-channel marketing permissions commerce records, in the order the
118
+ * brand's own register view sends them.
119
+ *
120
+ * Named here rather than written out at the call site because each one is a
121
+ * name a composition can author a tick for — see `authoredTick` in the submit.
122
+ */
123
+ const AUTH_MARKETING_CHANNEL_FLAGS = [
124
+ 'email_allowed',
125
+ 'sms_allowed',
126
+ 'call_allowed',
127
+ 'whatsapp_allowed'
128
+ ] as const;
155
129
 
156
130
  /**
157
131
  * Functional auth block: full registration form (name, surname, e-mail,
@@ -184,6 +158,25 @@ const RegisterFormBlock = ({
184
158
  getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
185
159
  );
186
160
 
161
+ /**
162
+ * The modal this form is inside, if any. Read before the destination is
163
+ * resolved, because it decides that destination's default: a sign-in inside
164
+ * a drawer is a detour from the page the shopper is on, so `stay` is right
165
+ * there and `fixed` — the historical push — is right on a page.
166
+ */
167
+ const hostModalId = useModalHostId();
168
+ /**
169
+ * Whether finishing closes the modal this form is inside. On here because a
170
+ * sign-in leaves nothing to read; the forms whose success state IS the point
171
+ * ("check your inbox") default to off. See `useHostModalCloser`.
172
+ */
173
+ const closeHostModalOnSuccess = readToggle(
174
+ block.properties?.closeHostModalOnSuccess,
175
+ currentBreakpoint,
176
+ true
177
+ );
178
+ const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
179
+
187
180
  const submitLabel = prop('submitLabel', 'Create account');
188
181
  // Empty on purpose: without a busy label the submit caption stays put while
189
182
  // the request runs, which is what published blocks render today.
@@ -227,6 +220,54 @@ const RegisterFormBlock = ({
227
220
  'otpLabel',
228
221
  'A verification code was sent. Please confirm it to finish.'
229
222
  );
223
+ const captchaLabel = prop(
224
+ 'captchaLabel',
225
+ 'Please complete the security check and try again.'
226
+ );
227
+ /**
228
+ * Whether this form offers the security check commerce escalates to. Off by
229
+ * default because `useCaptcha` fetches the site key on mount — see
230
+ * `auth/auth-captcha.tsx`.
231
+ */
232
+ const showCaptcha = readToggle(
233
+ block.properties?.showCaptcha,
234
+ currentBreakpoint,
235
+ false
236
+ );
237
+ /**
238
+ * Which credentials provider and which commerce endpoint the sign-up goes
239
+ * to. Authored for the same reason the Auth Form authors them: the provider
240
+ * id and the `formType` are how a brand points a form at its own provider,
241
+ * and a brand whose url map names the endpoint differently had no way to say
242
+ * so — the values were compiled in.
243
+ *
244
+ * `register` is the default because that is the key the core provider's url
245
+ * map actually has (`@akinon/next/data/urls.ts`, `user.register`). The
246
+ * compiled-in `registration` resolved to `undefined`, so the sign-up POSTed
247
+ * to `${commerceUrl}undefined` and no account was ever created.
248
+ */
249
+ const providerId = prop('providerId', DEFAULT_PROVIDER_ID);
250
+ const formType = prop('formType', DEFAULT_FORM_TYPE);
251
+ const callbackUrl = prop('callbackUrl', '/');
252
+ /**
253
+ * Where a finished sign-up leaves the shopper, and what one who is already
254
+ * signed in sees here. Same three modes and same default as the Auth Form —
255
+ * see `auth/auth-destination.ts`.
256
+ */
257
+ const afterSignIn = resolveOption<AuthAfterSignIn>(
258
+ block.properties?.afterSignIn,
259
+ currentBreakpoint,
260
+ 'fixed',
261
+ AUTH_AFTER_SIGN_IN_MODES as unknown as AuthAfterSignIn[]
262
+ );
263
+ const whenSignedIn = resolveOption<WhenSignedIn>(
264
+ block.properties?.whenSignedIn,
265
+ currentBreakpoint,
266
+ 'form',
267
+ WHEN_SIGNED_IN_MODES as unknown as WhenSignedIn[]
268
+ );
269
+ const signedInLabel = prop('signedInLabel', 'You are already signed in.');
270
+ const signedInHref = prop('signedInHref', '/account');
230
271
  // Where the SMS-verification challenge sends the shopper: a step key (or
231
272
  // `flow:step`) whose step holds an `otp-form` block. Empty keeps the old
232
273
  // behaviour — the otpLabel message — so published compositions are
@@ -252,6 +293,20 @@ const RegisterFormBlock = ({
252
293
  );
253
294
 
254
295
  const router = useRouter();
296
+ const search = useSearchParams();
297
+ const pathname = usePathname();
298
+ const { status } = useSession();
299
+ // `loading` counts as a guest — see the note in auth-form-block.
300
+ const isSignedIn = status === 'authenticated' && !isDesigner;
301
+ const destination = resolveAuthDestination({
302
+ mode: afterSignIn,
303
+ authored: callbackUrl,
304
+ search,
305
+ // A sign-in inside a drawer is a detour: a destination that names nowhere
306
+ // in particular means there is nowhere to go. An authored one still wins.
307
+ inModal: Boolean(hostModalId),
308
+ pathname
309
+ });
255
310
  const { locale, defaultLocaleValue } = useLocalization();
256
311
  const contextFlowId = useContext(AuthFlowIdContext);
257
312
 
@@ -279,6 +334,37 @@ const RegisterFormBlock = ({
279
334
  // Per FIELD, as opposed to `error`, which is what the backend answered about
280
335
  // the attempt as a whole.
281
336
  const [fieldErrors, setFieldErrors] = useState<AuthFieldErrors>({});
337
+ /**
338
+ * The security check's api. A ref, not state: the form reads it at SUBMIT
339
+ * time, and re-rendering a whole sign-up form every time the widget changes
340
+ * its mind would be a re-render for nothing.
341
+ */
342
+ const captchaRef = useRef<AuthCaptchaApi | null>(null);
343
+ const holdCaptchaApi = React.useCallback((api: AuthCaptchaApi) => {
344
+ captchaRef.current = api;
345
+ }, []);
346
+
347
+ /**
348
+ * A shopper who is already signed in, taken where they were going. `replace`
349
+ * so the Back button does not put them on a sign-up form again.
350
+ */
351
+ React.useEffect(() => {
352
+ if (!isSignedIn || whenSignedIn !== 'redirect') return;
353
+
354
+ router.replace(resolveSignedInDestination(signedInHref, search));
355
+ }, [isSignedIn, whenSignedIn, signedInHref, router, search]);
356
+
357
+ /**
358
+ * The form's contents, wrapped so a Security Check block (or the form's own
359
+ * slot) can reach the challenge. Left exactly as they were when the form
360
+ * does not offer one, which is every published composition.
361
+ */
362
+ const withCaptcha = (contents: React.ReactNode) =>
363
+ showCaptcha ? (
364
+ <AuthCaptchaHost onApi={holdCaptchaApi}>{contents}</AuthCaptchaHost>
365
+ ) : (
366
+ contents
367
+ );
282
368
  const [isSubmitting, setIsSubmitting] = useState(false);
283
369
 
284
370
  const setField = (key: keyof typeof values) => (
@@ -321,6 +407,14 @@ const RegisterFormBlock = ({
321
407
  // Submitting inside the designer would create a real commerce account.
322
408
  if (isDesigner) return;
323
409
 
410
+ // A live session must not be handed a second account. Enforced whatever
411
+ // the `whenSignedIn` policy is — see auth-form-block.
412
+ if (isSignedIn) {
413
+ setError(signedInLabel);
414
+
415
+ return;
416
+ }
417
+
324
418
  // Held onto because `currentTarget` is only the form for as long as the
325
419
  // handler is on the stack, and the consent question below is asked of the
326
420
  // markup rather than of state.
@@ -349,7 +443,7 @@ const RegisterFormBlock = ({
349
443
  }
350
444
 
351
445
  const payload: Record<string, string | boolean> = {
352
- formType: FORM_TYPE,
446
+ formType,
353
447
  // The credentials provider looks the locale up in Settings and throws
354
448
  // when it is missing, so an explicit value is always sent.
355
449
  locale: locale || defaultLocaleValue || ''
@@ -440,16 +534,44 @@ const RegisterFormBlock = ({
440
534
  return;
441
535
  }
442
536
 
443
- // Commerce requires both consent flags; one tick gates them because both
444
- // are mandatory to register at all — unless the composition made the tick
445
- // optional, and then the shopper's actual answer is what travels rather
446
- // than a yes nobody gave.
447
- payload.is_allowed = legalControl ? agreedToTerms : true;
448
- payload.confirm = payload.is_allowed;
449
- payload.email_allowed = agreedToMarketing;
450
- payload.sms_allowed = agreedToMarketing;
451
- payload.call_allowed = agreedToMarketing;
452
- payload.whatsapp_allowed = agreedToMarketing;
537
+ /**
538
+ * A flag the composition asked about ITSELF, or `undefined` when it did
539
+ * not ask.
540
+ *
541
+ * Only a tick counts: a Custom field named after a flag would answer
542
+ * `checked === false` on a text input and post a refusal the shopper never
543
+ * gave, so the query is narrowed to checkboxes.
544
+ */
545
+ const authoredTick = (name: string): boolean | undefined => {
546
+ if (!isComposed) return undefined;
547
+
548
+ const control = formElement.querySelector(
549
+ `input[type="checkbox"][name="${name}"]`
550
+ ) as HTMLInputElement | null;
551
+
552
+ return control ? control.checked : undefined;
553
+ };
554
+
555
+ const agreedToLegal = legalControl ? agreedToTerms : true;
556
+
557
+ payload.is_allowed = agreedToLegal;
558
+ payload.confirm = authoredTick('confirm') ?? agreedToLegal;
559
+
560
+ /**
561
+ * The four marketing channels, each carrying the answer the shopper
562
+ * actually gave.
563
+ *
564
+ * They used to be fanned out from the single marketing tick, which turned
565
+ * a composition that asks per channel into a lie: a shopper who ticked
566
+ * e-mail and left SMS alone was recorded as refusing all four. A consent
567
+ * record is a legal record, so a channel the composition asks about
568
+ * carries its own tick, and only the channels nobody asked about fall back
569
+ * to the one blanket answer — which is what a childless fixed form, and
570
+ * every composition that shows a single marketing row, still sends.
571
+ */
572
+ AUTH_MARKETING_CHANNEL_FLAGS.forEach((name) => {
573
+ payload[name] = authoredTick(name) ?? agreedToMarketing;
574
+ });
453
575
 
454
576
  // Commerce expects bare digits; the mask characters brands render around
455
577
  // the phone input would fail its length validation.
@@ -468,20 +590,62 @@ const RegisterFormBlock = ({
468
590
  setIsSubmitting(true);
469
591
 
470
592
  try {
471
- const response = await signIn(PROVIDER_ID, {
472
- // After the payload, not before it: these decide whether next-auth
473
- // hands back a response or navigates away, and a body key must never
474
- // be able to answer that question. `RESERVED_SUBMIT_KEYS` already
475
- // keeps them out of the payload; this is the belt to that braces.
476
- ...payload,
477
- redirect: false,
478
- callbackUrl: '/'
479
- });
593
+ /**
594
+ * One attempt. Called twice at most: a captcha escalation is answered
595
+ * and the same body is sent again, which is what the brand's own view
596
+ * does — the shopper solved the challenge, so making them press the
597
+ * button again would be the storefront's failure, not theirs.
598
+ */
599
+ const attempt = (solvedNow?: boolean) =>
600
+ signIn(providerId, {
601
+ // Sent only while the form offers a challenge, so a storefront
602
+ // without one keeps posting exactly the body it posts today.
603
+ ...(showCaptcha
604
+ ? {
605
+ captchaValidated:
606
+ solvedNow ?? captchaRef.current?.validated ?? false
607
+ }
608
+ : null),
609
+ // After the payload, not before it: these decide whether next-auth
610
+ // hands back a response or navigates away, and a body key must never
611
+ // be able to answer that question. `RESERVED_SUBMIT_KEYS` already
612
+ // keeps them out of the payload; this is the belt to that braces.
613
+ ...payload,
614
+ redirect: false,
615
+ callbackUrl
616
+ });
480
617
 
481
- if (!response || response.error) {
482
- const errorDetail = signInErrorDetail(response);
618
+ let response = await attempt();
619
+
620
+ if (
621
+ response?.error &&
622
+ showCaptcha &&
623
+ readAuthError(signInErrorDetail(response)).hasCaptchaChallenge &&
624
+ (await captchaRef.current?.validate())
625
+ ) {
626
+ // `true`, not the ref: what `validate` just resolved IS the answer, and
627
+ // the ref only catches up on the host's next render.
628
+ response = await attempt(true);
629
+ }
483
630
 
484
- if (hasOtpChallenge(errorDetail)) {
631
+ if (!response || response.error) {
632
+ /**
633
+ * What commerce actually refused, split into the sentence for the
634
+ * form-level slot and the sentences that belong under a field.
635
+ *
636
+ * Re-split against the form on screen: commerce names a field this
637
+ * composition may not have authored, and a message hung on a name no
638
+ * control submits under is a message nobody ever sees.
639
+ */
640
+ const reading = splitByKnownFields(
641
+ readAuthError(signInErrorDetail(response), {
642
+ otp: otpLabel,
643
+ captcha: captchaLabel
644
+ }),
645
+ formElement
646
+ );
647
+
648
+ if (reading.hasOtpChallenge) {
485
649
  // Resolved BEFORE the handoff is parked, so the record can say which
486
650
  // journey it belongs to.
487
651
  const resolvedStep = resolveAuthStepTarget(
@@ -502,7 +666,7 @@ const RegisterFormBlock = ({
502
666
  setPendingOtp({
503
667
  kind: 'register',
504
668
  phone: typeof payload.phone === 'string' ? payload.phone : '',
505
- providerId: PROVIDER_ID,
669
+ providerId,
506
670
  payload,
507
671
  scope: resolvedStep ? resolvedStep.flowId : contextFlowId,
508
672
  stepKey: resolvedStep?.stepKey
@@ -514,13 +678,47 @@ const RegisterFormBlock = ({
514
678
  }
515
679
  }
516
680
 
517
- setError(readAuthError(errorDetail, otpLabel) || errorLabel);
681
+ // Server-side field errors land where client-side ones already do, so
682
+ // "this e-mail is already registered" reads under the e-mail input
683
+ // instead of as a JSON blob above the form.
684
+ setFieldErrors((current) => ({ ...current, ...reading.fieldMessages }));
685
+ focusFirstAuthFieldError(formElement, reading.fieldMessages);
686
+ setError(authFormMessage(reading, errorLabel));
687
+
518
688
  return;
519
689
  }
520
690
 
691
+ /**
692
+ * The surface this form finished inside.
693
+ *
694
+ * Closing the modal it is IN is the default because it is never a
695
+ * surprise: a shopper who signed in inside the drawer is done with the
696
+ * drawer. `closeModalsOnSuccess` keeps its own, wider meaning — close
697
+ * EVERY open modal — for a composition that authored it, which is what a
698
+ * form nested two overlays deep needs.
699
+ *
700
+ * The live GANT drawer is why the default had to change: its OTP form has
701
+ * the toggle on and its login and register forms do not, so signing in
702
+ * with an e-mail left the drawer covering the page.
703
+ */
704
+ /**
705
+ * Both, not either: a composition that ticked the legacy toggle asked
706
+ * for "every open modal", and the wide close is a superset — so the
707
+ * narrow one runs unconditionally and simply finds nothing left to do.
708
+ * Sequencing them the other way round (an `else`) would have left every
709
+ * already-published drawer on the wide branch forever, which is the one
710
+ * behaviour this family exists to stop.
711
+ */
521
712
  if (closeModalsOnSuccess) closeAllModals();
713
+ closeHostModal();
714
+
715
+ // The new session has to reach the page either way; in `stay` mode this
716
+ // refresh IS the whole navigation.
522
717
  router.refresh();
523
- router.push(response.url || '/');
718
+
719
+ // Our own resolved path rather than next-auth's echo of it: this one has
720
+ // been through the same-origin check. `null` is `stay`.
721
+ if (destination) router.push(destination);
524
722
  } catch {
525
723
  setError(errorLabel);
526
724
  } finally {
@@ -640,9 +838,31 @@ const RegisterFormBlock = ({
640
838
  * be embedding the storefront — and every copy was a place a new message
641
839
  * field had to be remembered.
642
840
  */
841
+ /**
842
+ * What the composition's Form State containers read. A registration ends in
843
+ * a sign-in or a handoff to the OTP step, so there is no success SCREEN
844
+ * here either — what can be scoped is the submission running and the
845
+ * refusal. See `auth/form-state-context.tsx`.
846
+ */
847
+ const formStateValue: AuthFormStateValue = {
848
+ screen: 'form',
849
+ busy: isSubmitting,
850
+ hasError: Boolean(error)
851
+ };
852
+
643
853
  const renderChildren = () =>
644
854
  renderComposedChildren({
645
- blocks: childBlocks,
855
+ // A child that is not a state container is shared chrome, and shows
856
+ // whatever the form is doing.
857
+ blocks: childBlocks.filter((childBlock) =>
858
+ showsInFormState({
859
+ block: childBlock,
860
+ state: formStateValue,
861
+ isDesigner,
862
+ selectedBlockId,
863
+ currentBreakpoint
864
+ })
865
+ ),
646
866
  placeholderId,
647
867
  sectionId,
648
868
  isDesigner,
@@ -650,6 +870,21 @@ const RegisterFormBlock = ({
650
870
  currentBreakpoint
651
871
  });
652
872
 
873
+ // Told, rather than shown a form they cannot use — see auth-form-block.
874
+ if (isSignedIn && whenSignedIn !== 'form') {
875
+ return (
876
+ <div
877
+ data-register-form=""
878
+ className="w-full"
879
+ data-register-form-state="signed-in"
880
+ {...partAttrs('signed-in')}
881
+ role="status"
882
+ >
883
+ {signedInLabel}
884
+ </div>
885
+ );
886
+ }
887
+
653
888
  if (isComposed) {
654
889
  /**
655
890
  * The composed form: the children carry the inputs (`auth-field`), the
@@ -664,6 +899,7 @@ const RegisterFormBlock = ({
664
899
  them into the flags commerce names individually — so its children
665
900
  are where a membership or marketing row actually works. */}
666
901
  <AuthConsentHostProvider value>
902
+ <AuthFormStateProvider value={formStateValue}>
667
903
  <AuthFieldErrorsProvider value={fieldErrors}>
668
904
  <form
669
905
  id={formHtmlId || undefined}
@@ -684,7 +920,17 @@ const RegisterFormBlock = ({
684
920
  gap: 'var(--register-form-gap, 12px)'
685
921
  }}
686
922
  >
687
- {renderChildren()}
923
+ {withCaptcha(
924
+ <>
925
+ {renderChildren()}
926
+
927
+ {/* The form draws the challenge only when the composition
928
+ did not place a Security Check block — the same
929
+ relationship the built-in error line has with a Form Error
930
+ block. */}
931
+ {!containsCaptchaBlock(childBlocks) && <AuthCaptchaSlot />}
932
+ </>
933
+ )}
688
934
 
689
935
  {/* The fallback for a composition that has not placed a Form Error
690
936
  block; see `auth/form-error-context.tsx` for why placing one is
@@ -723,6 +969,7 @@ const RegisterFormBlock = ({
723
969
  )}
724
970
  </form>
725
971
  </AuthFieldErrorsProvider>
972
+ </AuthFormStateProvider>
726
973
  </AuthConsentHostProvider>
727
974
  </AuthFormErrorProvider>
728
975
  );
@@ -794,6 +1041,11 @@ const RegisterFormBlock = ({
794
1041
  false
795
1042
  )}
796
1043
 
1044
+ {/* A childless form has nowhere for a Security Check block to be, so it
1045
+ draws the challenge itself — above its own submit, where the brand's
1046
+ own view puts it. */}
1047
+ {withCaptcha(<AuthCaptchaSlot />)}
1048
+
797
1049
  {error && (
798
1050
  <div data-register-form-error="" {...partAttrs('error')} role="alert">
799
1051
  {error}