@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,17 +1,16 @@
1
1
  'use client';
2
2
 
3
- import React, { useContext, useRef, useState } from 'react';
3
+ import React, { useContext, useState } from 'react';
4
4
  import NextLink from 'next/link';
5
- import { usePathname, useRouter, useSearchParams } from 'next/navigation';
6
- import { signIn, useSession } from 'next-auth/react';
5
+ import { useRouter } from 'next/navigation';
6
+ import { signIn } from 'next-auth/react';
7
7
  import { useLocalization } from '@akinon/next/hooks';
8
- import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
8
+ import { getResponsiveValue, partAttrs } from '../utils';
9
9
  import {
10
10
  AuthFieldErrors,
11
11
  AuthFieldErrorsProvider,
12
12
  clearAuthFieldError,
13
- collectAuthFieldErrors,
14
- focusFirstAuthFieldError
13
+ collectAuthFieldErrors
15
14
  } from '../auth/auth-field-errors';
16
15
  import {
17
16
  clearFormSubmitState,
@@ -21,58 +20,61 @@ import {
21
20
  AuthFormErrorProvider,
22
21
  containsAuthErrorBlock
23
22
  } from '../auth/form-error-context';
24
- import {
25
- AuthFormStateProvider,
26
- AuthFormStateValue,
27
- showsInFormState
28
- } from '../auth/form-state-context';
29
23
  import { emitCommerceEvent } from '../utils/analytics';
30
24
  import { readToggle } from './product-block-utils';
31
25
  import { closeAllModals } from '../overlays/overlay-bus';
32
- import {
33
- useHostModalCloser,
34
- useModalHostId
35
- } from '../overlays/modal-host-context';
36
26
  import {
37
27
  AuthFlowIdContext,
38
28
  resolveAuthStepTarget,
39
29
  setAuthStep
40
30
  } from '../auth/auth-flow-bus';
41
31
  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';
59
32
  import { BlockRendererProps } from './block-renderer-registry';
60
33
 
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
+
61
72
  /**
62
73
  * Names a composed field must not take, because the form and next-auth already
63
74
  * mean something by them. A local copy of the register form's set, kept local
64
75
  * for the same reason `readAuthError` is: making it an export would change a
65
76
  * shipped module's public surface.
66
77
  */
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
-
76
78
  const RESERVED_SUBMIT_KEYS = new Set([
77
79
  'formType',
78
80
  'locale',
@@ -153,56 +155,10 @@ const AuthFormBlock = ({
153
155
  getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
154
156
  );
155
157
 
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
-
175
158
  const rawMode = prop('mode', 'login');
176
159
  const mode = rawMode === 'register' ? 'register' : 'login';
177
160
  const providerId = prop('providerId', 'default');
178
161
  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');
206
162
  const submitLabel = prop(
207
163
  'submitLabel',
208
164
  mode === 'register' ? 'Create account' : 'Log in'
@@ -211,31 +167,6 @@ const AuthFormBlock = ({
211
167
  // the request is in flight, which is what every published block does today.
212
168
  const busyLabel = prop('busyLabel', '');
213
169
  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
- );
239
170
  const showLabels = readToggle(
240
171
  block.properties?.showLabels,
241
172
  currentBreakpoint,
@@ -257,16 +188,9 @@ const AuthFormBlock = ({
257
188
  * Empty keeps the URL, so every published form behaves as before.
258
189
  */
259
190
  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
- */
267
191
  const formType =
268
192
  mode === 'register'
269
- ? prop('registerFormType', 'register')
193
+ ? prop('registerFormType', 'registration')
270
194
  : prop('loginFormType', 'login');
271
195
  // Off by default: a form on a plain auth page must not start closing modals
272
196
  // it does not live in. Compositions inside a drawer turn it on so a login
@@ -298,25 +222,6 @@ const AuthFormBlock = ({
298
222
  );
299
223
 
300
224
  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
- });
320
225
  const { locale, defaultLocaleValue } = useLocalization();
321
226
  const contextFlowId = useContext(AuthFlowIdContext);
322
227
  const forgotPasswordStep = resolveAuthStepTarget(
@@ -334,16 +239,6 @@ const AuthFormBlock = ({
334
239
  // Per FIELD, as opposed to `error` above, which is what the auth backend
335
240
  // answered about the attempt as a whole.
336
241
  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
- }, []);
347
242
  const [isSubmitting, setIsSubmitting] = useState(false);
348
243
 
349
244
  const setField = (key: keyof typeof values) => (
@@ -385,31 +280,6 @@ const AuthFormBlock = ({
385
280
  return () => clearFormSubmitState(formHtmlId);
386
281
  }, [formHtmlId, isSubmitting]);
387
282
 
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
-
413
283
  const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
414
284
  const target = event.target as { name?: string } | null;
415
285
 
@@ -424,18 +294,6 @@ const AuthFormBlock = ({
424
294
 
425
295
  if (isDesigner || isSubmitting) return;
426
296
 
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
-
439
297
  // Before anything is sent: the form carries `noValidate`, so nothing else
440
298
  // is going to stop an empty submit, and nothing else would have told the
441
299
  // shopper WHICH field is the problem.
@@ -489,53 +347,12 @@ const AuthFormBlock = ({
489
347
  setIsSubmitting(true);
490
348
 
491
349
  try {
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
- }
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
+ });
539
356
 
540
357
  if (!response || response.error) {
541
358
  const errorDetail = signInErrorDetail(response);
@@ -551,18 +368,7 @@ const AuthFormBlock = ({
551
368
  });
552
369
  }
553
370
 
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));
371
+ setError(readAuthError(errorDetail) || errorLabel);
566
372
 
567
373
  return;
568
374
  }
@@ -576,38 +382,9 @@ const AuthFormBlock = ({
576
382
  emitCommerceEvent('login', { method: providerId });
577
383
  }
578
384
 
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
- */
600
385
  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.
605
386
  router.refresh();
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);
387
+ router.push(callbackUrl);
611
388
  } catch {
612
389
  setError(errorLabel);
613
390
  } finally {
@@ -740,33 +517,9 @@ const AuthFormBlock = ({
740
517
  * be embedding the storefront — and every copy was a place a new message
741
518
  * field had to be remembered.
742
519
  */
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
-
757
520
  const renderChildren = () =>
758
521
  renderComposedChildren({
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
- ),
522
+ blocks: childBlocks,
770
523
  placeholderId,
771
524
  sectionId,
772
525
  isDesigner,
@@ -774,25 +527,6 @@ const AuthFormBlock = ({
774
527
  currentBreakpoint
775
528
  });
776
529
 
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
-
796
530
  if (isComposed) {
797
531
  /**
798
532
  * The composed form: children carry the fields (native inputs named by
@@ -801,14 +535,9 @@ const AuthFormBlock = ({
801
535
  * the designer wires their own CTA.
802
536
  */
803
537
  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);
808
538
 
809
539
  return (
810
540
  <AuthFormErrorProvider value={error}>
811
- <AuthFormStateProvider value={formStateValue}>
812
541
  <AuthFieldErrorsProvider value={fieldErrors}>
813
542
  <form
814
543
  id={formHtmlId || undefined}
@@ -830,13 +559,7 @@ const AuthFormBlock = ({
830
559
  gap: 'var(--auth-form-gap, 12px)'
831
560
  }}
832
561
  >
833
- {withCaptcha(
834
- <>
835
- {renderChildren()}
836
-
837
- {!captchaIsAuthored && <AuthCaptchaSlot />}
838
- </>
839
- )}
562
+ {renderChildren()}
840
563
 
841
564
  {forgotPasswordControl}
842
565
 
@@ -872,7 +595,6 @@ const AuthFormBlock = ({
872
595
  )}
873
596
  </form>
874
597
  </AuthFieldErrorsProvider>
875
- </AuthFormStateProvider>
876
598
  </AuthFormErrorProvider>
877
599
  );
878
600
  }
@@ -933,11 +655,6 @@ const AuthFormBlock = ({
933
655
 
934
656
  {forgotPasswordControl}
935
657
 
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
-
941
658
  {error && (
942
659
  <div data-auth-form-error="" {...partAttrs('error')} role="alert">
943
660
  {error}
@@ -79,14 +79,10 @@ 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';
84
82
  import AuthConsentBlock from './auth-consent-block';
85
83
  import AuthFieldBlock from './auth-field-block';
86
84
  import OtpPhaseBlock from './otp-phase-block';
87
85
  import OtpCodeInputBlock from './otp-code-input-block';
88
- import CaptchaBlock from './captcha-block';
89
- import ResetPasswordBlock from './reset-password-block';
90
86
  import StoreLocatorBlock from './store-locator-block';
91
87
  import FaqAccordionBlock from './faq-accordion-block';
92
88
  import ShareBlock from './share-block';
@@ -228,14 +224,10 @@ blockRendererRegistry.register('auth-flow', AuthFlowBlock);
228
224
  blockRendererRegistry.register('auth-step', AuthStepBlock);
229
225
  blockRendererRegistry.register('auth-step-title', AuthStepTitleBlock);
230
226
  blockRendererRegistry.register('auth-error', AuthErrorBlock);
231
- blockRendererRegistry.register('auth-success', AuthSuccessBlock);
232
- blockRendererRegistry.register('form-state', FormStateBlock);
233
227
  blockRendererRegistry.register('auth-consent', AuthConsentBlock);
234
228
  blockRendererRegistry.register('auth-field', AuthFieldBlock);
235
229
  blockRendererRegistry.register('otp-phase', OtpPhaseBlock);
236
230
  blockRendererRegistry.register('otp-code-input', OtpCodeInputBlock);
237
- blockRendererRegistry.register('captcha', CaptchaBlock);
238
- blockRendererRegistry.register('reset-password', ResetPasswordBlock);
239
231
  blockRendererRegistry.register('store-locator', StoreLocatorBlock);
240
232
  blockRendererRegistry.register('faq-accordion', FaqAccordionBlock);
241
233
  blockRendererRegistry.register('share', ShareBlock);
@@ -87,29 +87,8 @@ const ButtonBlock = ({
87
87
  return 'Click Me';
88
88
  };
89
89
 
90
- const submitFormId = String(
91
- getResponsiveValue(block.properties?.submitFormId, currentBreakpoint, '')
92
- ).trim();
93
- /**
94
- * A detached CTA is a real submit button and has to behave like one while the
95
- * form it drives is working: staying bright and clickable through a request
96
- * is what makes a shopper press it twice. An empty id costs no subscription,
97
- * so this is safe to read unconditionally — as a hook must be.
98
- */
99
- const isSubmittedFormBusy = useFormSubmitState(submitFormId);
100
-
101
90
  const textValue = block.properties?.text || block.value;
102
- /**
103
- * What the caption says while the form this button drives is working.
104
- *
105
- * Empty keeps the caption put, which is what every published button does —
106
- * the same absence-preserving default the forms' own `busyLabel` carries.
107
- */
108
- const busyTextValue = block.properties?.busyText;
109
- const text =
110
- isSubmittedFormBusy && busyTextValue
111
- ? getLocalizedText(busyTextValue)
112
- : getLocalizedText(textValue);
91
+ const text = getLocalizedText(textValue);
113
92
 
114
93
  const url = block.properties?.url
115
94
  ? String(getResponsiveValue(block.properties.url, currentBreakpoint, '#'))
@@ -154,6 +133,16 @@ const ButtonBlock = ({
154
133
  * live in a pinned bottom bar while the fields stay in the flow above.
155
134
  * Forces the <button> branch: an anchor cannot submit anything.
156
135
  */
136
+ const submitFormId = String(
137
+ getResponsiveValue(block.properties?.submitFormId, currentBreakpoint, '')
138
+ ).trim();
139
+ /**
140
+ * A detached CTA is a real submit button and has to behave like one while the
141
+ * form it drives is working: staying bright and clickable through a request
142
+ * is what makes a shopper press it twice. An empty id costs no subscription,
143
+ * so this is safe to read unconditionally — as a hook must be.
144
+ */
145
+ const isSubmittedFormBusy = useFormSubmitState(submitFormId);
157
146
 
158
147
  const iconUrl = block.properties?.icon
159
148
  ? String(getResponsiveValue(block.properties.icon, currentBreakpoint, ''))
@@ -671,27 +660,8 @@ const ButtonBlock = ({
671
660
  // root styles HERE instead of onto the designer wrapper, so padding
672
661
  // and border land once, on the element the shopper actually clicks —
673
662
  // not once on the wrapper and again inline (which doubled them).
674
- //
675
- // The states travel with it: `disabled` alone changed nothing a
676
- // shopper could see, because an authored background and colour
677
- // override what a browser paints on a disabled button — so a
678
- // detached CTA stayed bright and clickable-looking through the whole
679
- // request, which is exactly what makes someone press it twice.
680
- {...partAttrs('root', {
681
- loading: isSubmittedFormBusy,
682
- disabled: isSubmittedFormBusy
683
- })}
684
- style={
685
- isSubmittedFormBusy
686
- ? {
687
- ...finalStyles,
688
- // A default a designer does not have to author, and the same
689
- // one the forms' own submit buttons use.
690
- opacity: 0.7,
691
- cursor: 'not-allowed'
692
- }
693
- : finalStyles
694
- }
663
+ {...partAttrs('root')}
664
+ style={finalStyles}
695
665
  onClick={handleClick}
696
666
  {...action.ariaProps}
697
667
  >
@@ -3,7 +3,6 @@
3
3
  import React, { useState } from 'react';
4
4
  import { useUpdatePasswordMutation } from '@akinon/next/data/client/account';
5
5
  import { getResponsiveValue, partAttrs } from '../utils';
6
- import { useHostModalCloser } from '../overlays/modal-host-context';
7
6
  import { BlockRendererProps } from './block-renderer-registry';
8
7
  import { readToggle } from './product-block-utils';
9
8
 
@@ -64,19 +63,6 @@ const ChangePasswordFormBlock = ({
64
63
  getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
65
64
  );
66
65
 
67
- /**
68
- * Whether finishing closes the modal this block is inside. OFF by default
69
- * here, because this block's success state is something the shopper is meant
70
- * to read — see `useHostModalCloser`.
71
- */
72
- const closeHostModalOnSuccess = readToggle(
73
- block.properties?.closeHostModalOnSuccess,
74
- currentBreakpoint,
75
- false
76
- );
77
- const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
78
-
79
-
80
66
  const submitLabel = text('submitLabel', 'Change password');
81
67
  const submitAlign = text('submitAlign', 'stretch');
82
68
  const currentPasswordLabel = text('currentPasswordLabel', 'Current password');
@@ -139,7 +125,6 @@ const ChangePasswordFormBlock = ({
139
125
  await updatePassword(values).unwrap();
140
126
  setValues(EMPTY_FORM);
141
127
  setIsDone(true);
142
- closeHostModal();
143
128
  } catch (requestError) {
144
129
  setError(readPasswordError(requestError, errorMessage));
145
130
  }