@akinon/pz-theme 2.0.95-beta.1 → 2.0.95-rc.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 (60) hide show
  1. package/CHANGELOG.md +33 -15
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-commerce-events.test.tsx +1 -5
  4. package/src/__tests__/auth-form-error-block.test.tsx +0 -2
  5. package/src/__tests__/auth-form-validation.test.tsx +0 -2
  6. package/src/__tests__/auth-slot-coverage.test.ts +1 -9
  7. package/src/__tests__/auth-step-title.test.tsx +0 -2
  8. package/src/__tests__/detached-submit-state.test.tsx +2 -80
  9. package/src/__tests__/forgot-password-composed.test.tsx +0 -2
  10. package/src/__tests__/otp-form-composed.test.tsx +0 -2
  11. package/src/__tests__/password-reset-step.test.tsx +0 -2
  12. package/src/__tests__/register-form-composed.test.tsx +4 -253
  13. package/src/__tests__/registry-parity.test.ts +0 -12
  14. package/src/auth/auth-field-errors.tsx +0 -31
  15. package/src/blocks/account-button-block.tsx +0 -25
  16. package/src/blocks/add-to-cart-block.tsx +0 -15
  17. package/src/blocks/auth-field-block.tsx +3 -13
  18. package/src/blocks/auth-flow-block.tsx +3 -45
  19. package/src/blocks/auth-form-block.tsx +54 -337
  20. package/src/blocks/block-renderer-registry.tsx +0 -8
  21. package/src/blocks/button-block.tsx +13 -43
  22. package/src/blocks/change-password-form-block.tsx +0 -15
  23. package/src/blocks/forgot-password-block.tsx +9 -132
  24. package/src/blocks/newsletter-form-block.tsx +0 -16
  25. package/src/blocks/otp-form-block.tsx +67 -161
  26. package/src/blocks/profile-form-block.tsx +0 -13
  27. package/src/blocks/register-form-block.tsx +110 -362
  28. package/src/chrome/__tests__/theme-config-source.test.ts +1 -2
  29. package/src/chrome/placeholder-slugs.ts +0 -3
  30. package/src/chrome/route-slug.ts +0 -11
  31. package/src/index.ts +0 -10
  32. package/src/overlays/__tests__/modal-registry.test.ts +1 -5
  33. package/src/overlays/modal-chrome-css.ts +1 -8
  34. package/src/overlays/modal-preview-host.tsx +2 -7
  35. package/src/overlays/modal-registry.ts +1 -38
  36. package/src/overlays/overlay-surface.tsx +0 -23
  37. package/src/overlays/overlay-tokens.ts +0 -12
  38. package/src/overlays/theme-modal-mount.tsx +6 -85
  39. package/src/utils/visibility-rules.ts +0 -114
  40. package/src/__tests__/auth-captcha.test.tsx +0 -261
  41. package/src/__tests__/auth-destination.test.tsx +0 -348
  42. package/src/__tests__/auth-error-reading.test.ts +0 -232
  43. package/src/__tests__/auth-flow-scope.test.tsx +0 -162
  44. package/src/__tests__/auth-form-success-block.test.tsx +0 -429
  45. package/src/__tests__/block-action-audience.test.ts +0 -154
  46. package/src/__tests__/form-state-composition.test.tsx +0 -484
  47. package/src/__tests__/modal-login-close.test.tsx +0 -366
  48. package/src/__tests__/reset-password-composed.test.tsx +0 -385
  49. package/src/auth/auth-captcha.tsx +0 -125
  50. package/src/auth/auth-destination.ts +0 -168
  51. package/src/auth/form-state-context.tsx +0 -170
  52. package/src/auth/form-success-context.tsx +0 -56
  53. package/src/auth/read-auth-error.ts +0 -332
  54. package/src/blocks/auth-success-block.tsx +0 -85
  55. package/src/blocks/captcha-block.tsx +0 -61
  56. package/src/blocks/form-state-block.tsx +0 -84
  57. package/src/blocks/reset-password-block.tsx +0 -717
  58. package/src/overlays/__tests__/modal-loading.test.tsx +0 -235
  59. package/src/overlays/modal-host-context.tsx +0 -66
  60. package/src/overlays/overlay-loading.tsx +0 -144
@@ -1,32 +1,22 @@
1
1
  'use client';
2
2
 
3
- import React, { useContext, useRef, useState } from 'react';
4
- import { usePathname, useRouter, useSearchParams } from 'next/navigation';
5
- import { signIn, useSession } from 'next-auth/react';
3
+ import React, { useContext, useState } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { signIn } from 'next-auth/react';
6
6
  import { useLocalization } from '@akinon/next/hooks';
7
- import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
7
+ import { getResponsiveValue, partAttrs } 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';
14
10
  import {
15
11
  AuthFieldErrors,
16
12
  AuthFieldErrorsProvider,
17
13
  clearAuthFieldError,
18
- collectAuthFieldErrors,
19
- focusFirstAuthFieldError
14
+ collectAuthFieldErrors
20
15
  } from '../auth/auth-field-errors';
21
16
  import {
22
17
  AuthFormErrorProvider,
23
18
  containsAuthErrorBlock
24
19
  } from '../auth/form-error-context';
25
- import {
26
- AuthFormStateProvider,
27
- AuthFormStateValue,
28
- showsInFormState
29
- } from '../auth/form-state-context';
30
20
  import {
31
21
  AUTH_CONSENT_NAMES,
32
22
  AuthConsentHostProvider
@@ -42,29 +32,60 @@ import {
42
32
  setAuthStep,
43
33
  setPendingOtp
44
34
  } 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';
66
35
  import { BlockRendererProps } from './block-renderer-registry';
67
36
 
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
+
68
89
  /**
69
90
  * Names a composed field must not take, because the form and next-auth already
70
91
  * mean something by them.
@@ -88,6 +109,27 @@ const RESERVED_SUBMIT_KEYS = new Set([
88
109
  'json'
89
110
  ]);
90
111
 
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
+
91
133
  /**
92
134
  * Where the serialised AuthError[] actually lives depends on the next-auth
93
135
  * major. v4 puts it into `error` itself; v5 keeps `error` at the generic
@@ -105,27 +147,11 @@ const signInErrorDetail = (
105
147
 
106
148
  /**
107
149
  * The commerce registration endpoint is reached through next-auth's credentials
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.
150
+ * provider, exactly like the brand's own register view: the provider id is
151
+ * `default` and the url segment is selected by `formType`.
112
152
  */
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;
153
+ const PROVIDER_ID = 'default';
154
+ const FORM_TYPE = 'registration';
129
155
 
130
156
  /**
131
157
  * Functional auth block: full registration form (name, surname, e-mail,
@@ -158,25 +184,6 @@ const RegisterFormBlock = ({
158
184
  getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
159
185
  );
160
186
 
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
-
180
187
  const submitLabel = prop('submitLabel', 'Create account');
181
188
  // Empty on purpose: without a busy label the submit caption stays put while
182
189
  // the request runs, which is what published blocks render today.
@@ -220,54 +227,6 @@ const RegisterFormBlock = ({
220
227
  'otpLabel',
221
228
  'A verification code was sent. Please confirm it to finish.'
222
229
  );
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');
271
230
  // Where the SMS-verification challenge sends the shopper: a step key (or
272
231
  // `flow:step`) whose step holds an `otp-form` block. Empty keeps the old
273
232
  // behaviour — the otpLabel message — so published compositions are
@@ -293,20 +252,6 @@ const RegisterFormBlock = ({
293
252
  );
294
253
 
295
254
  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
- });
310
255
  const { locale, defaultLocaleValue } = useLocalization();
311
256
  const contextFlowId = useContext(AuthFlowIdContext);
312
257
 
@@ -334,37 +279,6 @@ const RegisterFormBlock = ({
334
279
  // Per FIELD, as opposed to `error`, which is what the backend answered about
335
280
  // the attempt as a whole.
336
281
  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
- );
368
282
  const [isSubmitting, setIsSubmitting] = useState(false);
369
283
 
370
284
  const setField = (key: keyof typeof values) => (
@@ -407,14 +321,6 @@ const RegisterFormBlock = ({
407
321
  // Submitting inside the designer would create a real commerce account.
408
322
  if (isDesigner) return;
409
323
 
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
-
418
324
  // Held onto because `currentTarget` is only the form for as long as the
419
325
  // handler is on the stack, and the consent question below is asked of the
420
326
  // markup rather than of state.
@@ -443,7 +349,7 @@ const RegisterFormBlock = ({
443
349
  }
444
350
 
445
351
  const payload: Record<string, string | boolean> = {
446
- formType,
352
+ formType: FORM_TYPE,
447
353
  // The credentials provider looks the locale up in Settings and throws
448
354
  // when it is missing, so an explicit value is always sent.
449
355
  locale: locale || defaultLocaleValue || ''
@@ -534,44 +440,16 @@ const RegisterFormBlock = ({
534
440
  return;
535
441
  }
536
442
 
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
- });
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;
575
453
 
576
454
  // Commerce expects bare digits; the mask characters brands render around
577
455
  // the phone input would fail its length validation.
@@ -590,62 +468,20 @@ const RegisterFormBlock = ({
590
468
  setIsSubmitting(true);
591
469
 
592
470
  try {
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
- });
617
-
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
- }
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
+ });
630
480
 
631
481
  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) {
482
+ const errorDetail = signInErrorDetail(response);
483
+
484
+ if (hasOtpChallenge(errorDetail)) {
649
485
  // Resolved BEFORE the handoff is parked, so the record can say which
650
486
  // journey it belongs to.
651
487
  const resolvedStep = resolveAuthStepTarget(
@@ -666,7 +502,7 @@ const RegisterFormBlock = ({
666
502
  setPendingOtp({
667
503
  kind: 'register',
668
504
  phone: typeof payload.phone === 'string' ? payload.phone : '',
669
- providerId,
505
+ providerId: PROVIDER_ID,
670
506
  payload,
671
507
  scope: resolvedStep ? resolvedStep.flowId : contextFlowId,
672
508
  stepKey: resolvedStep?.stepKey
@@ -678,47 +514,13 @@ const RegisterFormBlock = ({
678
514
  }
679
515
  }
680
516
 
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
-
517
+ setError(readAuthError(errorDetail, otpLabel) || errorLabel);
688
518
  return;
689
519
  }
690
520
 
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
- */
712
521
  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.
717
522
  router.refresh();
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);
523
+ router.push(response.url || '/');
722
524
  } catch {
723
525
  setError(errorLabel);
724
526
  } finally {
@@ -838,31 +640,9 @@ const RegisterFormBlock = ({
838
640
  * be embedding the storefront — and every copy was a place a new message
839
641
  * field had to be remembered.
840
642
  */
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
-
853
643
  const renderChildren = () =>
854
644
  renderComposedChildren({
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
- ),
645
+ blocks: childBlocks,
866
646
  placeholderId,
867
647
  sectionId,
868
648
  isDesigner,
@@ -870,21 +650,6 @@ const RegisterFormBlock = ({
870
650
  currentBreakpoint
871
651
  });
872
652
 
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
-
888
653
  if (isComposed) {
889
654
  /**
890
655
  * The composed form: the children carry the inputs (`auth-field`), the
@@ -899,7 +664,6 @@ const RegisterFormBlock = ({
899
664
  them into the flags commerce names individually — so its children
900
665
  are where a membership or marketing row actually works. */}
901
666
  <AuthConsentHostProvider value>
902
- <AuthFormStateProvider value={formStateValue}>
903
667
  <AuthFieldErrorsProvider value={fieldErrors}>
904
668
  <form
905
669
  id={formHtmlId || undefined}
@@ -920,17 +684,7 @@ const RegisterFormBlock = ({
920
684
  gap: 'var(--register-form-gap, 12px)'
921
685
  }}
922
686
  >
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
- )}
687
+ {renderChildren()}
934
688
 
935
689
  {/* The fallback for a composition that has not placed a Form Error
936
690
  block; see `auth/form-error-context.tsx` for why placing one is
@@ -969,7 +723,6 @@ const RegisterFormBlock = ({
969
723
  )}
970
724
  </form>
971
725
  </AuthFieldErrorsProvider>
972
- </AuthFormStateProvider>
973
726
  </AuthConsentHostProvider>
974
727
  </AuthFormErrorProvider>
975
728
  );
@@ -1041,11 +794,6 @@ const RegisterFormBlock = ({
1041
794
  false
1042
795
  )}
1043
796
 
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
-
1049
797
  {error && (
1050
798
  <div data-register-form-error="" {...partAttrs('error')} role="alert">
1051
799
  {error}
@@ -93,8 +93,7 @@ describe('readThemeConfig', () => {
93
93
  lockScroll: true,
94
94
  padding: undefined,
95
95
  showSheetHandle: true,
96
- stretchContent: false,
97
- showLoading: true
96
+ stretchContent: false
98
97
  },
99
98
  autoTrigger: undefined,
100
99
  visibilityRules: undefined
@@ -63,9 +63,6 @@ export const PLACEHOLDER_SLUGS = {
63
63
  // Auth
64
64
  register: 'register-page-body',
65
65
  forgotPassword: 'forgot-password-body',
66
- // The screen the reset MAIL lands on — a different page from the one that
67
- // ASKS for the mail. `reset-password-block` is what fills it.
68
- resetPassword: 'reset-password-body',
69
66
  guestLogin: 'guest-login-body',
70
67
 
71
68
  // Content
@@ -71,13 +71,6 @@ const DEFAULT_RULES: RouteSlugRule[] = [
71
71
  PLACEHOLDER_SLUGS.forgotPassword
72
72
  ],
73
73
  [/^\/users\/password\/reset(\/|$)/, PLACEHOLDER_SLUGS.forgotPassword],
74
- // The screen the reset MAIL lands on, which is a different page from the one
75
- // above: that one ASKS for the mail, this one is where the link goes. The
76
- // token rides in the path as a catch-all — and may itself contain slashes —
77
- // so the rule stops at the first boundary and lets the block read the rest.
78
- // Ordering is safe either way: `/users/password/reset` and `/users/reset`
79
- // cannot both match a path.
80
- [/^\/users\/reset(\/|$)/, PLACEHOLDER_SLUGS.resetPassword],
81
74
  [/^\/(users\/)?auth\/guest(\/|$)/, PLACEHOLDER_SLUGS.guestLogin],
82
75
  [/^\/(users\/)?auth\/?$/, PLACEHOLDER_SLUGS.auth],
83
76
 
@@ -150,10 +143,6 @@ const PAGE_MOUNTED_SLUGS = new Set<string>([
150
143
  PLACEHOLDER_SLUGS.auth,
151
144
  PLACEHOLDER_SLUGS.register,
152
145
  PLACEHOLDER_SLUGS.forgotPassword,
153
- // Mounted by its own layout beside the catch-all page that reads the token,
154
- // for the same reason the two above are: the page is a client component, so
155
- // the handover cannot live in it.
156
- PLACEHOLDER_SLUGS.resetPassword,
157
146
  PLACEHOLDER_SLUGS.guestLogin,
158
147
  PLACEHOLDER_SLUGS.flatPage,
159
148
  // Mounted by the not-found boundary itself, which renders on any unmatched