@akinon/pz-theme 2.0.95-rc.0 → 2.0.95-rc.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 +34 -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,16 +1,17 @@
1
1
  'use client';
2
2
 
3
- import React, { useContext, useState } from 'react';
3
+ import React, { useContext, useRef, useState } from 'react';
4
4
  import NextLink from 'next/link';
5
- import { useRouter } from 'next/navigation';
6
- import { signIn } from 'next-auth/react';
5
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
6
+ import { signIn, useSession } from 'next-auth/react';
7
7
  import { useLocalization } from '@akinon/next/hooks';
8
- import { getResponsiveValue, partAttrs } from '../utils';
8
+ import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
9
9
  import {
10
10
  AuthFieldErrors,
11
11
  AuthFieldErrorsProvider,
12
12
  clearAuthFieldError,
13
- collectAuthFieldErrors
13
+ collectAuthFieldErrors,
14
+ focusFirstAuthFieldError
14
15
  } from '../auth/auth-field-errors';
15
16
  import {
16
17
  clearFormSubmitState,
@@ -20,61 +21,58 @@ import {
20
21
  AuthFormErrorProvider,
21
22
  containsAuthErrorBlock
22
23
  } from '../auth/form-error-context';
24
+ import {
25
+ AuthFormStateProvider,
26
+ AuthFormStateValue,
27
+ showsInFormState
28
+ } from '../auth/form-state-context';
23
29
  import { emitCommerceEvent } from '../utils/analytics';
24
30
  import { readToggle } from './product-block-utils';
25
31
  import { closeAllModals } from '../overlays/overlay-bus';
32
+ import {
33
+ useHostModalCloser,
34
+ useModalHostId
35
+ } from '../overlays/modal-host-context';
26
36
  import {
27
37
  AuthFlowIdContext,
28
38
  resolveAuthStepTarget,
29
39
  setAuthStep
30
40
  } from '../auth/auth-flow-bus';
31
41
  import { renderComposedChildren } from './render-composed-children';
42
+ import {
43
+ authFormMessage,
44
+ readAuthError,
45
+ splitByKnownFields
46
+ } from '../auth/read-auth-error';
47
+ import {
48
+ AUTH_AFTER_SIGN_IN_MODES,
49
+ AuthAfterSignIn,
50
+ resolveAuthDestination,
51
+ resolveSignedInDestination
52
+ } from '../auth/auth-destination';
53
+ import {
54
+ AuthCaptchaApi,
55
+ AuthCaptchaHost,
56
+ AuthCaptchaSlot,
57
+ containsCaptchaBlock
58
+ } from '../auth/auth-captcha';
32
59
  import { BlockRendererProps } from './block-renderer-registry';
33
60
 
34
- /**
35
- * Extracts a readable message out of the next-auth error string. pz brands
36
- * serialise an AuthError[] into `error`, but a plain string is also possible,
37
- * so both shapes are handled.
38
- */
39
- const readAuthError = (error?: string | null): string => {
40
- if (!error) return '';
41
-
42
- try {
43
- const parsed = JSON.parse(error);
44
-
45
- if (!Array.isArray(parsed)) return String(error);
46
-
47
- const messages: string[] = [];
48
-
49
- parsed.forEach((entry) => {
50
- const data = (entry as { data?: unknown })?.data;
51
-
52
- if (Array.isArray(data)) {
53
- data.forEach((item) => {
54
- if (typeof item === 'string') {
55
- messages.push(item);
56
- } else if (item && typeof item === 'object') {
57
- const value = (item as { value?: unknown }).value;
58
- if (typeof value === 'string') messages.push(value);
59
- }
60
- });
61
- } else if (typeof data === 'string') {
62
- messages.push(data);
63
- }
64
- });
65
-
66
- return messages.join(' ') || String(error);
67
- } catch {
68
- return String(error);
69
- }
70
- };
71
-
72
61
  /**
73
62
  * Names a composed field must not take, because the form and next-auth already
74
63
  * mean something by them. A local copy of the register form's set, kept local
75
64
  * for the same reason `readAuthError` is: making it an export would change a
76
65
  * shipped module's public surface.
77
66
  */
67
+ /**
68
+ * What a form does for a shopper who is already signed in. Named here rather
69
+ * than in `auth-destination.ts` because it is a rendering decision, not a
70
+ * destination one — the register form and the OTP form read the same list.
71
+ */
72
+ export const WHEN_SIGNED_IN_MODES = ['form', 'message', 'redirect'] as const;
73
+
74
+ export type WhenSignedIn = (typeof WHEN_SIGNED_IN_MODES)[number];
75
+
78
76
  const RESERVED_SUBMIT_KEYS = new Set([
79
77
  'formType',
80
78
  'locale',
@@ -155,10 +153,56 @@ const AuthFormBlock = ({
155
153
  getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
156
154
  );
157
155
 
156
+ /**
157
+ * The modal this form is inside, if any. Read before the destination is
158
+ * resolved, because it decides that destination's default: a sign-in inside
159
+ * a drawer is a detour from the page the shopper is on, so `stay` is right
160
+ * there and `fixed` — the historical push — is right on a page.
161
+ */
162
+ const hostModalId = useModalHostId();
163
+ /**
164
+ * Whether finishing closes the modal this form is inside. On here because a
165
+ * sign-in leaves nothing to read; the forms whose success state IS the point
166
+ * ("check your inbox") default to off. See `useHostModalCloser`.
167
+ */
168
+ const closeHostModalOnSuccess = readToggle(
169
+ block.properties?.closeHostModalOnSuccess,
170
+ currentBreakpoint,
171
+ true
172
+ );
173
+ const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
174
+
158
175
  const rawMode = prop('mode', 'login');
159
176
  const mode = rawMode === 'register' ? 'register' : 'login';
160
177
  const providerId = prop('providerId', 'default');
161
178
  const callbackUrl = prop('callbackUrl', '/');
179
+ /**
180
+ * Where a finished sign-in leaves the shopper. `fixed` is the historical
181
+ * answer and stays the default, so no published composition moves; a drawer
182
+ * wants `stay` and a login PAGE wants `return`. See `auth/auth-destination`.
183
+ */
184
+ const afterSignIn = resolveOption<AuthAfterSignIn>(
185
+ block.properties?.afterSignIn,
186
+ currentBreakpoint,
187
+ 'fixed',
188
+ AUTH_AFTER_SIGN_IN_MODES as unknown as AuthAfterSignIn[]
189
+ );
190
+ /**
191
+ * What a shopper who is ALREADY signed in sees here.
192
+ *
193
+ * `form` is what shipped, and it is a dead end: a back button after signing
194
+ * in, a bookmarked /auth or a second tab shows a member a login form, and
195
+ * re-submitting it re-authenticates a live session. `message` says so;
196
+ * `redirect` takes them where they were going.
197
+ */
198
+ const whenSignedIn = resolveOption<WhenSignedIn>(
199
+ block.properties?.whenSignedIn,
200
+ currentBreakpoint,
201
+ 'form',
202
+ WHEN_SIGNED_IN_MODES as unknown as WhenSignedIn[]
203
+ );
204
+ const signedInLabel = prop('signedInLabel', 'You are already signed in.');
205
+ const signedInHref = prop('signedInHref', '/account');
162
206
  const submitLabel = prop(
163
207
  'submitLabel',
164
208
  mode === 'register' ? 'Create account' : 'Log in'
@@ -167,6 +211,31 @@ const AuthFormBlock = ({
167
211
  // the request is in flight, which is what every published block does today.
168
212
  const busyLabel = prop('busyLabel', '');
169
213
  const errorLabel = prop('errorLabel', 'Authentication failed.');
214
+ /**
215
+ * What the shopper is told when commerce escalates to a captcha after too
216
+ * many attempts. Nothing in this theme can ANSWER the challenge yet, so the
217
+ * least this can do is name it — the alternative, which shipped, was the
218
+ * literal `[{"type":"captcha"}]` in the error slot.
219
+ */
220
+ const captchaLabel = prop(
221
+ 'captchaLabel',
222
+ 'Please complete the security check and try again.'
223
+ );
224
+ /**
225
+ * Whether this form offers the security check commerce escalates to.
226
+ *
227
+ * Off by default, and not because the challenge is optional: `useCaptcha`
228
+ * fetches the site key on mount, and a storefront whose commerce never
229
+ * escalates should not pay a request per auth form for a widget it will
230
+ * never show. A brand that IS abuse-protected turns it on once, places a
231
+ * Captcha block where the challenge should appear (or lets the form draw it
232
+ * above the submit), and composed login stops dead-ending.
233
+ */
234
+ const showCaptcha = readToggle(
235
+ block.properties?.showCaptcha,
236
+ currentBreakpoint,
237
+ false
238
+ );
170
239
  const showLabels = readToggle(
171
240
  block.properties?.showLabels,
172
241
  currentBreakpoint,
@@ -188,9 +257,16 @@ const AuthFormBlock = ({
188
257
  * Empty keeps the URL, so every published form behaves as before.
189
258
  */
190
259
  const forgotPasswordStepTarget = prop('forgotPasswordStepTarget', '').trim();
260
+ /**
261
+ * `register`, not `registration`: the credentials provider resolves the
262
+ * endpoint as `user[formType]` and the core url map has only `register`, so
263
+ * the old default POSTed the sign-up to `${commerceUrl}undefined`. A brand
264
+ * whose own provider names the segment differently still says so here — that
265
+ * is what the property is for.
266
+ */
191
267
  const formType =
192
268
  mode === 'register'
193
- ? prop('registerFormType', 'registration')
269
+ ? prop('registerFormType', 'register')
194
270
  : prop('loginFormType', 'login');
195
271
  // Off by default: a form on a plain auth page must not start closing modals
196
272
  // it does not live in. Compositions inside a drawer turn it on so a login
@@ -222,6 +298,25 @@ const AuthFormBlock = ({
222
298
  );
223
299
 
224
300
  const router = useRouter();
301
+ const search = useSearchParams();
302
+ const pathname = usePathname();
303
+ const { status } = useSession();
304
+ /**
305
+ * `loading` counts as a guest on purpose. The opposite — withholding the
306
+ * form until the session is known — shows every shopper an empty panel for
307
+ * the first client render; this way the worst case is a member seeing the
308
+ * form for a beat, which is the state they are being moved out of anyway.
309
+ */
310
+ const isSignedIn = status === 'authenticated' && !isDesigner;
311
+ const destination = resolveAuthDestination({
312
+ mode: afterSignIn,
313
+ authored: callbackUrl,
314
+ search,
315
+ // A sign-in inside a drawer is a detour: a destination that names nowhere
316
+ // in particular means there is nowhere to go. An authored one still wins.
317
+ inModal: Boolean(hostModalId),
318
+ pathname
319
+ });
225
320
  const { locale, defaultLocaleValue } = useLocalization();
226
321
  const contextFlowId = useContext(AuthFlowIdContext);
227
322
  const forgotPasswordStep = resolveAuthStepTarget(
@@ -239,6 +334,16 @@ const AuthFormBlock = ({
239
334
  // Per FIELD, as opposed to `error` above, which is what the auth backend
240
335
  // answered about the attempt as a whole.
241
336
  const [fieldErrors, setFieldErrors] = useState<AuthFieldErrors>({});
337
+ /**
338
+ * The security check's api, kept in a ref rather than in state: the form
339
+ * reads `validated` and `validate` at SUBMIT time, and re-rendering the
340
+ * whole form every time the widget changes its mind would be a re-render for
341
+ * nothing. `AuthCaptchaHost` pushes it here.
342
+ */
343
+ const captchaRef = useRef<AuthCaptchaApi | null>(null);
344
+ const holdCaptchaApi = React.useCallback((api: AuthCaptchaApi) => {
345
+ captchaRef.current = api;
346
+ }, []);
242
347
  const [isSubmitting, setIsSubmitting] = useState(false);
243
348
 
244
349
  const setField = (key: keyof typeof values) => (
@@ -280,6 +385,31 @@ const AuthFormBlock = ({
280
385
  return () => clearFormSubmitState(formHtmlId);
281
386
  }, [formHtmlId, isSubmitting]);
282
387
 
388
+ /**
389
+ * A shopper who is already signed in, taken where they were going.
390
+ *
391
+ * `replace`, not `push`: the login screen is not a place to come back to
392
+ * with the browser's Back button — that is precisely the loop that shows a
393
+ * member a login form again.
394
+ */
395
+ React.useEffect(() => {
396
+ if (!isSignedIn || whenSignedIn !== 'redirect') return;
397
+
398
+ router.replace(resolveSignedInDestination(signedInHref, search));
399
+ }, [isSignedIn, whenSignedIn, signedInHref, router, search]);
400
+
401
+ /**
402
+ * The form's contents, wrapped so a Captcha block (or the form's own slot)
403
+ * can reach the challenge — and left exactly as they were when the form does
404
+ * not offer one, which is every published composition.
405
+ */
406
+ const withCaptcha = (contents: React.ReactNode) =>
407
+ showCaptcha ? (
408
+ <AuthCaptchaHost onApi={holdCaptchaApi}>{contents}</AuthCaptchaHost>
409
+ ) : (
410
+ contents
411
+ );
412
+
283
413
  const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
284
414
  const target = event.target as { name?: string } | null;
285
415
 
@@ -294,6 +424,18 @@ const AuthFormBlock = ({
294
424
 
295
425
  if (isDesigner || isSubmitting) return;
296
426
 
427
+ /**
428
+ * A live session must not be re-authenticated. Enforced whatever the
429
+ * `whenSignedIn` policy is: a composition left on `form` still has a form
430
+ * on screen, and submitting it would swap one valid session for another
431
+ * for no reason — with the shopper's password on the wire again.
432
+ */
433
+ if (isSignedIn) {
434
+ setError(signedInLabel);
435
+
436
+ return;
437
+ }
438
+
297
439
  // Before anything is sent: the form carries `noValidate`, so nothing else
298
440
  // is going to stop an empty submit, and nothing else would have told the
299
441
  // shopper WHICH field is the problem.
@@ -347,12 +489,53 @@ const AuthFormBlock = ({
347
489
  setIsSubmitting(true);
348
490
 
349
491
  try {
350
- const response = await signIn(providerId, {
351
- // After the payload: these decide whether next-auth answers at all.
352
- ...payload,
353
- redirect: false,
354
- callbackUrl
355
- });
492
+ /**
493
+ * One attempt. Called twice at most: a captcha escalation is answered
494
+ * and the same body is sent again, which is what the brands' own views
495
+ * do — the shopper solved the challenge, so asking them to press the
496
+ * button a second time would be the storefront's failure, not theirs.
497
+ */
498
+ const attempt = (solvedNow?: boolean) =>
499
+ signIn(providerId, {
500
+ // Sent only while the form offers a challenge, so a storefront
501
+ // without one keeps posting exactly the body it posts today.
502
+ ...(showCaptcha
503
+ ? {
504
+ captchaValidated:
505
+ solvedNow ?? captchaRef.current?.validated ?? false
506
+ }
507
+ : null),
508
+ // After the payload: these decide whether next-auth answers at all.
509
+ ...payload,
510
+ redirect: false,
511
+ callbackUrl
512
+ });
513
+
514
+ let response = await attempt();
515
+ let reading = splitByKnownFields(
516
+ readAuthError(signInErrorDetail(response), { captcha: captchaLabel }),
517
+ form
518
+ );
519
+
520
+ if (reading.hasCaptchaChallenge && showCaptcha) {
521
+ // `validate` reveals the widget and resolves false the first time;
522
+ // once the shopper has solved it the hook validates the answer and
523
+ // resolves true, and only then is there any point in retrying.
524
+ const solved = await captchaRef.current?.validate();
525
+
526
+ if (solved) {
527
+ // `true`, not the ref: what `validate` just resolved IS the answer, and
528
+ // the ref only catches up on the host's next render — which may not
529
+ // have happened by the time this retry goes out.
530
+ response = await attempt(true);
531
+ reading = splitByKnownFields(
532
+ readAuthError(signInErrorDetail(response), {
533
+ captcha: captchaLabel
534
+ }),
535
+ form
536
+ );
537
+ }
538
+ }
356
539
 
357
540
  if (!response || response.error) {
358
541
  const errorDetail = signInErrorDetail(response);
@@ -368,7 +551,18 @@ const AuthFormBlock = ({
368
551
  });
369
552
  }
370
553
 
371
- setError(readAuthError(errorDetail) || errorLabel);
554
+ // Field-level refusals ("no account with this e-mail") go under the
555
+ // field, the way client-side validation already does; only what
556
+ // belongs to the form as a whole stays above it.
557
+ //
558
+ // `reading` is the same refusal `main` read with `readAuthError`, only
559
+ // already split by field — `splitByKnownFields` wraps that very call
560
+ // above, and `authFormMessage` keeps `errorLabel` as its fallback. So
561
+ // the message a shopper sees is unchanged for a form-level refusal and
562
+ // moves under the field for one that names a field.
563
+ setFieldErrors((current) => ({ ...current, ...reading.fieldMessages }));
564
+ focusFirstAuthFieldError(form, reading.fieldMessages);
565
+ setError(authFormMessage(reading, errorLabel));
372
566
 
373
567
  return;
374
568
  }
@@ -382,9 +576,38 @@ const AuthFormBlock = ({
382
576
  emitCommerceEvent('login', { method: providerId });
383
577
  }
384
578
 
579
+ /**
580
+ * The surface this form finished inside.
581
+ *
582
+ * Closing the modal it is IN is the default because it is never a
583
+ * surprise: a shopper who signed in inside the drawer is done with the
584
+ * drawer. `closeModalsOnSuccess` keeps its own, wider meaning — close
585
+ * EVERY open modal — for a composition that authored it, which is what a
586
+ * form nested two overlays deep needs.
587
+ *
588
+ * The live GANT drawer is why the default had to change: its OTP form has
589
+ * the toggle on and its login and register forms do not, so signing in
590
+ * with an e-mail left the drawer covering the page.
591
+ */
592
+ /**
593
+ * Both, not either: a composition that ticked the legacy toggle asked
594
+ * for "every open modal", and the wide close is a superset — so the
595
+ * narrow one runs unconditionally and simply finds nothing left to do.
596
+ * Sequencing them the other way round (an `else`) would have left every
597
+ * already-published drawer on the wide branch forever, which is the one
598
+ * behaviour this family exists to stop.
599
+ */
385
600
  if (closeModalsOnSuccess) closeAllModals();
601
+ closeHostModal();
602
+
603
+ // The new session has to reach the page either way; in `stay` mode this
604
+ // refresh IS the whole navigation.
386
605
  router.refresh();
387
- router.push(callbackUrl);
606
+
607
+ // Our own resolved path rather than next-auth's echo of it: the response
608
+ // url is whatever we handed in, and this one has been through the
609
+ // same-origin check. `null` is `stay` — there is nowhere to go.
610
+ if (destination) router.push(destination);
388
611
  } catch {
389
612
  setError(errorLabel);
390
613
  } finally {
@@ -517,9 +740,33 @@ const AuthFormBlock = ({
517
740
  * be embedding the storefront — and every copy was a place a new message
518
741
  * field had to be remembered.
519
742
  */
743
+ /**
744
+ * What the composition's Form State containers read.
745
+ *
746
+ * A sign-in has no success SCREEN — it is in, and the page moves on — so the
747
+ * screen here is always the form itself; what a composition can scope to is
748
+ * the submission running and the refusal, which is what a "signing you in…"
749
+ * panel and a designed error box need.
750
+ */
751
+ const formStateValue: AuthFormStateValue = {
752
+ screen: 'form',
753
+ busy: isSubmitting,
754
+ hasError: Boolean(error)
755
+ };
756
+
520
757
  const renderChildren = () =>
521
758
  renderComposedChildren({
522
- blocks: childBlocks,
759
+ // A child that is not a state container is shared chrome and always
760
+ // shows; see `auth/form-state-context.tsx`.
761
+ blocks: childBlocks.filter((childBlock) =>
762
+ showsInFormState({
763
+ block: childBlock,
764
+ state: formStateValue,
765
+ isDesigner,
766
+ selectedBlockId,
767
+ currentBreakpoint
768
+ })
769
+ ),
523
770
  placeholderId,
524
771
  sectionId,
525
772
  isDesigner,
@@ -527,6 +774,25 @@ const AuthFormBlock = ({
527
774
  currentBreakpoint
528
775
  });
529
776
 
777
+ /**
778
+ * Told, rather than shown a form they cannot use. The `redirect` policy has
779
+ * this too, for the beat before the navigation lands — and for a shopper
780
+ * whose browser refuses it.
781
+ */
782
+ if (isSignedIn && whenSignedIn !== 'form') {
783
+ return (
784
+ <div
785
+ data-auth-form=""
786
+ className="w-full"
787
+ data-auth-form-state="signed-in"
788
+ {...partAttrs('signed-in')}
789
+ role="status"
790
+ >
791
+ {signedInLabel}
792
+ </div>
793
+ );
794
+ }
795
+
530
796
  if (isComposed) {
531
797
  /**
532
798
  * The composed form: children carry the fields (native inputs named by
@@ -535,9 +801,14 @@ const AuthFormBlock = ({
535
801
  * the designer wires their own CTA.
536
802
  */
537
803
  const errorIsAuthored = containsAuthErrorBlock(childBlocks);
804
+ // The form draws the widget only when the composition did not place a
805
+ // Captcha block — same relationship the built-in error line has with a
806
+ // Form Error block.
807
+ const captchaIsAuthored = containsCaptchaBlock(childBlocks);
538
808
 
539
809
  return (
540
810
  <AuthFormErrorProvider value={error}>
811
+ <AuthFormStateProvider value={formStateValue}>
541
812
  <AuthFieldErrorsProvider value={fieldErrors}>
542
813
  <form
543
814
  id={formHtmlId || undefined}
@@ -559,7 +830,13 @@ const AuthFormBlock = ({
559
830
  gap: 'var(--auth-form-gap, 12px)'
560
831
  }}
561
832
  >
562
- {renderChildren()}
833
+ {withCaptcha(
834
+ <>
835
+ {renderChildren()}
836
+
837
+ {!captchaIsAuthored && <AuthCaptchaSlot />}
838
+ </>
839
+ )}
563
840
 
564
841
  {forgotPasswordControl}
565
842
 
@@ -595,6 +872,7 @@ const AuthFormBlock = ({
595
872
  )}
596
873
  </form>
597
874
  </AuthFieldErrorsProvider>
875
+ </AuthFormStateProvider>
598
876
  </AuthFormErrorProvider>
599
877
  );
600
878
  }
@@ -655,6 +933,11 @@ const AuthFormBlock = ({
655
933
 
656
934
  {forgotPasswordControl}
657
935
 
936
+ {/* A childless form has nowhere for a Captcha block to be, so it draws
937
+ the challenge itself — above its own submit, where the brands' own
938
+ views put it. */}
939
+ {withCaptcha(<AuthCaptchaSlot />)}
940
+
658
941
  {error && (
659
942
  <div data-auth-form-error="" {...partAttrs('error')} role="alert">
660
943
  {error}
@@ -0,0 +1,85 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { useAuthFormSuccess } from '../auth/form-success-context';
6
+ import { getResponsiveValue, partAttrs } from '../utils';
7
+ import { BlockRendererProps } from './block-renderer-registry';
8
+ import { DesignerPlaceholder } from './product-block-utils';
9
+
10
+ /**
11
+ * The confirmation the form around this block finished with.
12
+ *
13
+ * The success twin of the Form Error block, and the reason it exists is the
14
+ * same complaint: "the success message is not something I can manage". It was
15
+ * a node the form drew itself, reachable only as a style part — a brand could
16
+ * recolour "a reset link is on its way" and could not put an icon beside it, a
17
+ * heading above it, or the sentence anywhere other than where the form chose.
18
+ *
19
+ * As a block it is ordinary content, so everything a design wants AROUND the
20
+ * confirmation is an ordinary sibling. Placing it also keeps the composition on
21
+ * screen in the forms whose success state would otherwise replace it — which is
22
+ * the point: a block inside a form that has been swapped out draws nothing.
23
+ * When the whole screen should change instead, the form's Step After Success is
24
+ * still the tool for that, and it needs no block at all.
25
+ *
26
+ * Empty until the form has finished, so a shopper never sees a reserved gap. In
27
+ * the canvas it draws `previewMessage` instead, so the state can be dressed
28
+ * without having to reach it for real.
29
+ *
30
+ * Properties: previewMessage.
31
+ * Parts: root.
32
+ */
33
+ const AuthSuccessBlock = ({
34
+ block,
35
+ isDesigner = false,
36
+ currentBreakpoint = 'desktop'
37
+ }: BlockRendererProps) => {
38
+ const message = useAuthFormSuccess();
39
+
40
+ const previewMessage = String(
41
+ getResponsiveValue(
42
+ block.properties?.previewMessage,
43
+ currentBreakpoint,
44
+ 'Done. Check your inbox.'
45
+ )
46
+ ).trim();
47
+
48
+ // The canvas shows the state being styled; a storefront shows nothing until
49
+ // the form has actually finished. An author who empties the preview text gets
50
+ // the placeholder rather than an invisible block they cannot select.
51
+ const shown = message || (isDesigner ? previewMessage : '');
52
+
53
+ if (!shown) {
54
+ if (!isDesigner) return null;
55
+
56
+ return (
57
+ <DesignerPlaceholder label="Form success (shown once the form finishes)" />
58
+ );
59
+ }
60
+
61
+ return (
62
+ <div
63
+ data-auth-success=""
64
+ // Fills its block wrapper; see the note in auth-step-block.tsx.
65
+ className="w-full"
66
+ // Only the live message is announced. `role="status"` on the canvas
67
+ // preview would have a designer's screen reader read a confirmation for
68
+ // something that has not happened.
69
+ role={message ? 'status' : undefined}
70
+ data-auth-success-state={message ? 'success' : 'preview'}
71
+ {...partAttrs('root')}
72
+ // No inline colour, unlike its error twin: that block sets one because
73
+ // the auth forms paint their message through a variable a brand may
74
+ // already have set, while a confirmation has never had a colour of its
75
+ // own. So this line inherits the composition's text colour, and the
76
+ // block's own Text Color — emitted `!important` as every part style is —
77
+ // is what overrides it.
78
+ style={{ margin: 0 }}
79
+ >
80
+ {shown}
81
+ </div>
82
+ );
83
+ };
84
+
85
+ export default AuthSuccessBlock;
@@ -79,10 +79,14 @@ import AuthFlowBlock from './auth-flow-block';
79
79
  import AuthStepBlock from './auth-step-block';
80
80
  import AuthStepTitleBlock from './auth-step-title-block';
81
81
  import AuthErrorBlock from './auth-error-block';
82
+ import AuthSuccessBlock from './auth-success-block';
83
+ import FormStateBlock from './form-state-block';
82
84
  import AuthConsentBlock from './auth-consent-block';
83
85
  import AuthFieldBlock from './auth-field-block';
84
86
  import OtpPhaseBlock from './otp-phase-block';
85
87
  import OtpCodeInputBlock from './otp-code-input-block';
88
+ import CaptchaBlock from './captcha-block';
89
+ import ResetPasswordBlock from './reset-password-block';
86
90
  import StoreLocatorBlock from './store-locator-block';
87
91
  import FaqAccordionBlock from './faq-accordion-block';
88
92
  import ShareBlock from './share-block';
@@ -224,10 +228,14 @@ blockRendererRegistry.register('auth-flow', AuthFlowBlock);
224
228
  blockRendererRegistry.register('auth-step', AuthStepBlock);
225
229
  blockRendererRegistry.register('auth-step-title', AuthStepTitleBlock);
226
230
  blockRendererRegistry.register('auth-error', AuthErrorBlock);
231
+ blockRendererRegistry.register('auth-success', AuthSuccessBlock);
232
+ blockRendererRegistry.register('form-state', FormStateBlock);
227
233
  blockRendererRegistry.register('auth-consent', AuthConsentBlock);
228
234
  blockRendererRegistry.register('auth-field', AuthFieldBlock);
229
235
  blockRendererRegistry.register('otp-phase', OtpPhaseBlock);
230
236
  blockRendererRegistry.register('otp-code-input', OtpCodeInputBlock);
237
+ blockRendererRegistry.register('captcha', CaptchaBlock);
238
+ blockRendererRegistry.register('reset-password', ResetPasswordBlock);
231
239
  blockRendererRegistry.register('store-locator', StoreLocatorBlock);
232
240
  blockRendererRegistry.register('faq-accordion', FaqAccordionBlock);
233
241
  blockRendererRegistry.register('share', ShareBlock);