@akinon/pz-theme 2.0.95-rc.0 → 2.0.95

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 (62) hide show
  1. package/CHANGELOG.md +8 -33
  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/__tests__/slider-block.test.tsx +265 -1
  30. package/src/blocks/account-button-block.tsx +25 -0
  31. package/src/blocks/add-to-cart-block.tsx +15 -0
  32. package/src/blocks/auth-field-block.tsx +13 -3
  33. package/src/blocks/auth-flow-block.tsx +45 -3
  34. package/src/blocks/auth-form-block.tsx +337 -54
  35. package/src/blocks/auth-success-block.tsx +85 -0
  36. package/src/blocks/block-renderer-registry.tsx +8 -0
  37. package/src/blocks/button-block.tsx +43 -13
  38. package/src/blocks/captcha-block.tsx +61 -0
  39. package/src/blocks/change-password-form-block.tsx +15 -0
  40. package/src/blocks/forgot-password-block.tsx +132 -9
  41. package/src/blocks/form-state-block.tsx +84 -0
  42. package/src/blocks/newsletter-form-block.tsx +16 -0
  43. package/src/blocks/otp-form-block.tsx +161 -67
  44. package/src/blocks/profile-form-block.tsx +13 -0
  45. package/src/blocks/register-form-block.tsx +362 -110
  46. package/src/blocks/reset-password-block.tsx +717 -0
  47. package/src/blocks/slider-block.tsx +170 -4
  48. package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
  49. package/src/chrome/placeholder-slugs.ts +3 -0
  50. package/src/chrome/route-slug.ts +11 -0
  51. package/src/index.ts +10 -0
  52. package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
  53. package/src/overlays/__tests__/modal-registry.test.ts +5 -1
  54. package/src/overlays/modal-chrome-css.ts +8 -1
  55. package/src/overlays/modal-host-context.tsx +66 -0
  56. package/src/overlays/modal-preview-host.tsx +7 -2
  57. package/src/overlays/modal-registry.ts +38 -1
  58. package/src/overlays/overlay-loading.tsx +144 -0
  59. package/src/overlays/overlay-surface.tsx +23 -0
  60. package/src/overlays/overlay-tokens.ts +12 -0
  61. package/src/overlays/theme-modal-mount.tsx +85 -6
  62. package/src/utils/visibility-rules.ts +114 -0
@@ -20,7 +20,7 @@ import { BlockRendererProps } from './block-renderer-registry';
20
20
  * form owns those. Standalone (outside any form) it still renders, but
21
21
  * nothing collects its value, which the designer sees immediately.
22
22
  *
23
- * Properties: fieldType, label, placeholder, showLabel, required, and for
23
+ * Properties: fieldType, label, placeholder, showLabel, required, autoComplete, and for
24
24
  * a `gender` field the two option captions (femaleLabel, maleLabel).
25
25
  * Parts: field, label, input, error.
26
26
  */
@@ -143,6 +143,16 @@ const AuthFieldBlock = ({
143
143
  : preset.type;
144
144
 
145
145
  const label = prop('label', preset.label);
146
+ /**
147
+ * What a password manager is told this box is for.
148
+ *
149
+ * The preset was compiled in, which made one screen wrong on purpose: the
150
+ * `password` preset says `current-password`, so a composed SIGN-UP or a
151
+ * reset screen told the manager to offer a saved password instead of
152
+ * generating a new one, and what the shopper then typed was never saved.
153
+ * Absent keeps the preset, so every published field is untouched.
154
+ */
155
+ const autoComplete = prop('autoComplete', '').trim() || preset.autoComplete;
146
156
  const placeholder = prop('placeholder', '');
147
157
  const showLabel = readToggle(
148
158
  block.properties?.showLabel,
@@ -216,7 +226,7 @@ const AuthFieldBlock = ({
216
226
  */
217
227
  <select
218
228
  name={controlName}
219
- autoComplete={preset.autoComplete}
229
+ autoComplete={autoComplete}
220
230
  defaultValue=""
221
231
  aria-label={showLabel ? undefined : label}
222
232
  aria-invalid={errorMessage ? true : undefined}
@@ -235,7 +245,7 @@ const AuthFieldBlock = ({
235
245
  <input
236
246
  type={controlType}
237
247
  name={controlName}
238
- autoComplete={preset.autoComplete}
248
+ autoComplete={autoComplete}
239
249
  placeholder={placeholder || undefined}
240
250
  aria-label={showLabel ? undefined : label}
241
251
  aria-invalid={errorMessage ? true : undefined}
@@ -1,11 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useEffect } from 'react';
4
+ import { useSession } from 'next-auth/react';
4
5
  import { getResponsiveValue, partAttrs } from '../utils';
5
6
  import {
6
7
  ActiveAuthStepContext,
7
8
  AuthFlowIdContext,
8
- DEFAULT_AUTH_FLOW_ID,
9
9
  goBackAuthStep,
10
10
  registerAuthFlowMount,
11
11
  useAuthFlowState
@@ -74,9 +74,33 @@ const AuthFlowBlock = ({
74
74
  getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
75
75
  );
76
76
 
77
- const flowId = prop('flowId', '').trim() || DEFAULT_AUTH_FLOW_ID;
77
+ /**
78
+ * The name this journey answers to on the bus.
79
+ *
80
+ * Authored, it is shared: two flows carrying the same name show the same step
81
+ * — deliberate for one journey rendered twice, and for a trigger outside the
82
+ * flow that names `<flowId>:<step>`.
83
+ *
84
+ * Empty, it is the BLOCK's own, because the alternative was a collision the
85
+ * moment a brand did what the composition invites: a journey in the header
86
+ * drawer and a second one in a tab or a page body both fell back to one
87
+ * shared name, so opening the drawer on "register" moved the page's journey
88
+ * with it, and closing the drawer reset it. `DEFAULT_AUTH_FLOW_ID` stays the
89
+ * fallback for controls that know no flow at all — see `resolveAuthStepTarget`
90
+ * — so a composition that wants to be reachable that way names itself.
91
+ */
92
+ const flowId = prop('flowId', '').trim() || `flow-${block.id}`;
78
93
  const initialStep = prop('initialStep', '').trim();
79
94
  const previewStep = prop('previewStep', '').trim();
95
+ /**
96
+ * The step a shopper who is ALREADY signed in lands on.
97
+ *
98
+ * The state picks the step, which is the flow's whole idea — so a member who
99
+ * opens the drawer gets "Hoş geldin / Hesabım / Çıkış" composed out of
100
+ * ordinary blocks instead of the login form they have no use for. Empty
101
+ * keeps the historical behaviour: the flow does not care about the session.
102
+ */
103
+ const signedInStep = prop('signedInStep', '').trim();
80
104
  const backLabel = prop('backLabel', 'Back');
81
105
  const showBack = readToggle(
82
106
  block.properties?.showBack,
@@ -92,6 +116,13 @@ const AuthFlowBlock = ({
92
116
  );
93
117
 
94
118
  const flowState = useAuthFlowState(isDesigner ? '' : flowId);
119
+ const { status } = useSession();
120
+ /**
121
+ * `loading` is not treated as signed in: the flow would open on the member
122
+ * step for a guest and then swap. The other way round — a member seeing the
123
+ * login step for a beat — is the state they are being moved out of anyway.
124
+ */
125
+ const isSignedIn = status === 'authenticated' && !isDesigner;
95
126
 
96
127
  useEffect(() => {
97
128
  if (isDesigner) return undefined;
@@ -124,9 +155,20 @@ const AuthFlowBlock = ({
124
155
  if (selected) return stepKeyOf(selected, currentBreakpoint);
125
156
  }
126
157
 
158
+ /**
159
+ * A step the shopper NAVIGATED to still wins over the member step: a
160
+ * signed-in shopper who taps "change password" inside the journey is
161
+ * asking for that step, and answering with the welcome card would be the
162
+ * flow overruling them. The member step is the ENTRY, so it only leads
163
+ * while the bus has nothing to say.
164
+ */
127
165
  const candidates = isDesigner
128
166
  ? [previewStep, initialStep]
129
- : [flowState?.active ?? '', initialStep];
167
+ : [
168
+ flowState?.active ?? '',
169
+ ...(isSignedIn ? [signedInStep] : []),
170
+ initialStep
171
+ ];
130
172
 
131
173
  for (const candidate of candidates) {
132
174
  if (candidate && stepKeys.includes(candidate)) return candidate;
@@ -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}