@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
@@ -8,28 +8,12 @@ import {
8
8
  AuthFieldErrors,
9
9
  AuthFieldErrorsProvider,
10
10
  clearAuthFieldError,
11
- collectAuthFieldErrors,
12
- focusFirstAuthFieldError
11
+ collectAuthFieldErrors
13
12
  } from '../auth/auth-field-errors';
14
- import {
15
- authFormMessage,
16
- readCommerceError,
17
- splitByKnownFields
18
- } from '../auth/read-auth-error';
19
13
  import {
20
14
  AuthFormErrorProvider,
21
15
  containsAuthErrorBlock
22
16
  } from '../auth/form-error-context';
23
- import {
24
- AuthFormSuccessProvider,
25
- containsAuthSuccessBlock
26
- } from '../auth/form-success-context';
27
- import {
28
- AuthFormStateProvider,
29
- AuthFormStateValue,
30
- containsFormStateBlock,
31
- showsInFormState
32
- } from '../auth/form-state-context';
33
17
  import {
34
18
  clearFormSubmitState,
35
19
  publishFormSubmitState
@@ -40,17 +24,8 @@ import {
40
24
  setAuthStep
41
25
  } from '../auth/auth-flow-bus';
42
26
  import { renderComposedChildren } from './render-composed-children';
43
- import { useHostModalCloser } from '../overlays/modal-host-context';
44
27
  import { BlockRendererProps } from './block-renderer-registry';
45
28
 
46
- /**
47
- * The only name this form submits under, and therefore the only one a
48
- * field-level refusal can be hung on. Named as well as measured, because a
49
- * composition whose CTA is a detached Button still has to attribute
50
- * "no account with this address" to the address field.
51
- */
52
- const FORGOT_PASSWORD_FIELD_NAMES = ['email'] as const;
53
-
54
29
  /**
55
30
  * Functional auth block: e-mail input that triggers the password reset mail
56
31
  * through the v1 `forgotPassword` mutation. Behaviour is fixed (commerce sends
@@ -83,19 +58,6 @@ const ForgotPasswordBlock = ({
83
58
  getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
84
59
  );
85
60
 
86
- /**
87
- * Whether finishing closes the modal this block is inside. OFF by default
88
- * here, because this block's success state is something the shopper is meant
89
- * to read — see `useHostModalCloser`.
90
- */
91
- const closeHostModalOnSuccess = readToggle(
92
- block.properties?.closeHostModalOnSuccess,
93
- currentBreakpoint,
94
- false
95
- );
96
- const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
97
-
98
-
99
61
  const submitLabel = prop('submitLabel', 'Send reset link');
100
62
  // Empty on purpose: without a busy label the submit caption stays put while
101
63
  // the request runs, which is what published blocks render today.
@@ -181,21 +143,6 @@ const ForgotPasswordBlock = ({
181
143
  .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
182
144
  .sort((a, b) => (a.order || 0) - (b.order || 0));
183
145
  const isComposed = childBlocks.length > 0;
184
- /**
185
- * Whether the composition draws the confirmation itself — either with a Form
186
- * Success block carrying this form's sentence, or with a whole screen scoped
187
- * to the success state in a Form State container.
188
- *
189
- * Two things follow: the built-in success copy stands down, and the form is
190
- * NOT replaced by it — a block inside a form that has been swapped out is
191
- * unmounted and would draw nothing at all, which is the whole reason the
192
- * confirmation was unmanageable in the first place.
193
- */
194
- const successScreenIsComposed =
195
- isComposed && containsFormStateBlock(childBlocks, 'success', currentBreakpoint);
196
- const successIsAuthored =
197
- successScreenIsComposed ||
198
- (isComposed && containsAuthSuccessBlock(childBlocks));
199
146
 
200
147
  const [email, setEmail] = useState('');
201
148
  const [error, setError] = useState('');
@@ -238,14 +185,10 @@ const ForgotPasswordBlock = ({
238
185
  // Submitting inside the designer would send a real reset mail.
239
186
  if (isDesigner || isLoading) return;
240
187
 
241
- // Held onto because `currentTarget` is only the form for as long as the
242
- // handler runs, and the refusal is read after an await.
243
- const formElement = event.currentTarget;
244
-
245
188
  // The form carries `noValidate`, so this is what stops an empty submit —
246
189
  // and it says so under the field rather than in a browser bubble.
247
190
  const { errors: nextFieldErrors, firstInvalid } = collectAuthFieldErrors(
248
- formElement,
191
+ event.currentTarget,
249
192
  { required: requiredMessage, invalid: invalidMessage }
250
193
  );
251
194
 
@@ -261,7 +204,7 @@ const ForgotPasswordBlock = ({
261
204
  // form the way every other composed auth form reads its fields, rather
262
205
  // than from state no child writes to.
263
206
  const address = isComposed
264
- ? String(new FormData(formElement).get('email') ?? '').trim()
207
+ ? String(new FormData(event.currentTarget).get('email') ?? '').trim()
265
208
  : email;
266
209
 
267
210
  try {
@@ -273,28 +216,8 @@ const ForgotPasswordBlock = ({
273
216
  if (successStep) {
274
217
  setAuthStep(successStep.flowId, successStep.stepKey);
275
218
  }
276
-
277
- // Contradictory on purpose-tolerant: a composition that names a success
278
- // STEP and also asks to close gets the close, because a step inside a
279
- // closed drawer is moot. Off by default — "check your inbox" is the
280
- // point of this form's success state.
281
- closeHostModal();
282
- } catch (rejection) {
283
- /**
284
- * What commerce actually said, rather than "could not send" over a rate
285
- * limit the shopper cannot see. RTK Query puts the body on `data`; a
286
- * message naming the address goes under the field, which is where the
287
- * shopper is looking, and anything form-level stays above it.
288
- */
289
- const reading = splitByKnownFields(
290
- readCommerceError((rejection as { data?: unknown })?.data),
291
- formElement,
292
- FORGOT_PASSWORD_FIELD_NAMES
293
- );
294
-
295
- setFieldErrors((current) => ({ ...current, ...reading.fieldMessages }));
296
- focusFirstAuthFieldError(formElement, reading.fieldMessages);
297
- setError(authFormMessage(reading, errorLabel));
219
+ } catch {
220
+ setError(errorLabel);
298
221
  }
299
222
  };
300
223
 
@@ -326,26 +249,9 @@ const ForgotPasswordBlock = ({
326
249
  </button>
327
250
  ) : null;
328
251
 
329
- /** Finished — for real on a storefront, on the toggle in the canvas. */
330
- const inSuccessState =
331
- (isSuccess && !isDesigner) || (isDesigner && previewSuccess);
332
-
333
- /**
334
- * What the composition's Form State containers read. `busy` and `hasError`
335
- * sit beside the screen rather than replacing it, so a refusal does not take
336
- * the fields away with it — see `auth/form-state-context.tsx`.
337
- */
338
- const formStateValue: AuthFormStateValue = {
339
- screen: inSuccessState ? 'success' : 'form',
340
- busy: isLoading,
341
- hasError: Boolean(error)
342
- };
343
-
344
252
  // The success copy replaces the form so the shopper is not invited to submit
345
253
  // the same address twice; the root attribute is kept so styling still applies.
346
- // A composition that placed a Form Success block keeps its own layout
347
- // instead — see `successIsAuthored`.
348
- if (inSuccessState && !successIsAuthored) {
254
+ if ((isSuccess && !isDesigner) || (isDesigner && previewSuccess)) {
349
255
  return (
350
256
  <div
351
257
  data-forgot-password=""
@@ -373,10 +279,6 @@ const ForgotPasswordBlock = ({
373
279
 
374
280
  return (
375
281
  <AuthFormErrorProvider value={error}>
376
- {/* The confirmation, for the block the composition placed for it. Empty
377
- until the mail is away, so the block draws nothing before then. */}
378
- <AuthFormSuccessProvider value={inSuccessState ? successLabel : ''}>
379
- <AuthFormStateProvider value={formStateValue}>
380
282
  <AuthFieldErrorsProvider value={fieldErrors}>
381
283
  <form
382
284
  id={formHtmlId || undefined}
@@ -384,9 +286,7 @@ const ForgotPasswordBlock = ({
384
286
  data-forgot-password-composed=""
385
287
  // Fills its block wrapper; see the note in auth-step-block.tsx.
386
288
  className="w-full"
387
- data-forgot-password-state={
388
- inSuccessState ? 'success' : isLoading ? 'busy' : 'idle'
389
- }
289
+ data-forgot-password-state={isLoading ? 'busy' : 'idle'}
390
290
  {...partAttrs('form')}
391
291
  onSubmit={submit}
392
292
  // Our own message under the field instead of the browser's bubble
@@ -401,17 +301,7 @@ const ForgotPasswordBlock = ({
401
301
  }}
402
302
  >
403
303
  {renderComposedChildren({
404
- // Only the containers this screen calls for; everything that is
405
- // not a container is shared chrome and shows on all of them.
406
- blocks: childBlocks.filter((childBlock) =>
407
- showsInFormState({
408
- block: childBlock,
409
- state: formStateValue,
410
- isDesigner,
411
- selectedBlockId,
412
- currentBreakpoint
413
- })
414
- ),
304
+ blocks: childBlocks,
415
305
  placeholderId,
416
306
  sectionId,
417
307
  isDesigner,
@@ -432,14 +322,7 @@ const ForgotPasswordBlock = ({
432
322
  </div>
433
323
  )}
434
324
 
435
- {/* Kept in the success state where the confirmation is only a
436
- LINE — the address is still valid and the fields are still on
437
- screen, so pressing it again is a RESEND, the affordance a
438
- "check your inbox" screen normally wants. Dropped where a Form
439
- State container owns the success screen: the fields are then
440
- scoped away with it and this would be an orphan button under a
441
- composition that never asked for it. */}
442
- {showSubmit && !(inSuccessState && successScreenIsComposed) && (
325
+ {showSubmit && (
443
326
  <button
444
327
  type="submit"
445
328
  data-forgot-password-submit=""
@@ -461,14 +344,8 @@ const ForgotPasswordBlock = ({
461
344
  {isLoading && busyLabel ? busyLabel : submitLabel}
462
345
  </button>
463
346
  )}
464
-
465
- {/* The authored way back. Not when a container owns the screen:
466
- the way onward is then part of the composition. */}
467
- {inSuccessState && !successScreenIsComposed && backControl}
468
347
  </form>
469
348
  </AuthFieldErrorsProvider>
470
- </AuthFormStateProvider>
471
- </AuthFormSuccessProvider>
472
349
  </AuthFormErrorProvider>
473
350
  );
474
351
  }
@@ -3,8 +3,6 @@
3
3
  import React, { useState } from 'react';
4
4
  import { useEmailSubscriptionMutation } from '@akinon/next/data/client/misc';
5
5
  import { getResponsiveValue, partAttrs } from '../utils';
6
- import { useHostModalCloser } from '../overlays/modal-host-context';
7
- import { readToggle } from './product-block-utils';
8
6
  import { BlockRendererProps } from './block-renderer-registry';
9
7
 
10
8
  /** The wording the block has always shipped with; every label is editable. */
@@ -39,22 +37,9 @@ const NewsletterFormBlock = ({
39
37
  currentBreakpoint,
40
38
  fallback
41
39
  );
42
-
43
40
  return value === undefined || value === null ? fallback : String(value);
44
41
  };
45
42
 
46
- /**
47
- * Whether finishing closes the modal this block is inside. OFF by default
48
- * here, because this block's success state is something the shopper is meant
49
- * to read — see `useHostModalCloser`.
50
- */
51
- const closeHostModalOnSuccess = readToggle(
52
- block.properties?.closeHostModalOnSuccess,
53
- currentBreakpoint,
54
- false
55
- );
56
- const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
57
-
58
43
  const placeholder = prop('placeholder', 'Your email address');
59
44
  const inputLabel = prop('inputLabel', '');
60
45
  const submitLabel = prop('submitLabel', 'Subscribe');
@@ -86,7 +71,6 @@ const NewsletterFormBlock = ({
86
71
  // form itself is the consent, so it is always sent as true.
87
72
  await subscribe({ email: value, subscribe_contract: true }).unwrap();
88
73
  setStatus('success');
89
- closeHostModal();
90
74
  setEmail('');
91
75
  } catch {
92
76
  setErrorKind('request');
@@ -1,17 +1,13 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useContext, useEffect, useRef, useState } from 'react';
4
- import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4
+ import { useRouter } from 'next/navigation';
5
5
  import { signIn } from 'next-auth/react';
6
6
  import { useLocalization } from '@akinon/next/hooks';
7
7
  import { useOtpLoginMutation } from '@akinon/next/data/client/user';
8
8
  import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
9
9
  import { readToggle } from './product-block-utils';
10
10
  import { closeAllModals } from '../overlays/overlay-bus';
11
- import {
12
- useHostModalCloser,
13
- useModalHostId
14
- } from '../overlays/modal-host-context';
15
11
  import {
16
12
  AuthFlowIdContext,
17
13
  clearPendingOtp,
@@ -29,36 +25,51 @@ import {
29
25
  AuthFormErrorProvider,
30
26
  containsAuthErrorBlock
31
27
  } from '../auth/form-error-context';
32
- import {
33
- AuthFormStateProvider,
34
- AuthFormStateValue,
35
- showsInFormState
36
- } from '../auth/form-state-context';
37
28
  import {
38
29
  clearFormSubmitState,
39
30
  publishFormSubmitState
40
31
  } from '../auth/form-submit-bus';
41
32
  import { OtpFormApi, OtpFormProvider } from '../auth/otp-form-context';
42
33
  import { renderComposedChildren } from './render-composed-children';
43
- import {
44
- authFormMessage,
45
- readAuthError,
46
- splitByKnownFields
47
- } from '../auth/read-auth-error';
48
- import {
49
- AUTH_AFTER_SIGN_IN_MODES,
50
- AuthAfterSignIn,
51
- resolveAuthDestination
52
- } from '../auth/auth-destination';
34
+ import { BlockRendererProps } from './block-renderer-registry';
53
35
 
54
36
  /**
55
- * The two names an OTP journey submits under, and therefore the only two a
56
- * field-level refusal can be hung on. The verify handler has no form element
57
- * to measure — its CTA may be a detached Button outside the form — so the set
58
- * is named rather than read off the DOM.
37
+ * pz brands serialise an AuthError[] into next-auth's `error` string, but a
38
+ * plain string is also possible, so both shapes are handled. Kept local rather
39
+ * than imported from auth-form-block, for the same reason register-form keeps
40
+ * its own copy: exporting it would change a shipped module's public surface.
59
41
  */
60
- const OTP_FIELD_NAMES = ['code', 'phone'] as const;
61
- import { BlockRendererProps } from './block-renderer-registry';
42
+ const readAuthError = (error: string | null | undefined): string => {
43
+ if (!error) return '';
44
+
45
+ try {
46
+ const parsed = JSON.parse(error);
47
+ if (!Array.isArray(parsed)) return String(error);
48
+
49
+ const messages: string[] = [];
50
+
51
+ parsed.forEach((entry) => {
52
+ const data = (entry as { data?: unknown })?.data;
53
+
54
+ if (Array.isArray(data)) {
55
+ data.forEach((item) => {
56
+ if (typeof item === 'string') {
57
+ messages.push(item);
58
+ } else if (item && typeof item === 'object') {
59
+ const value = (item as { value?: unknown }).value;
60
+ if (typeof value === 'string') messages.push(value);
61
+ }
62
+ });
63
+ } else if (typeof data === 'string') {
64
+ messages.push(data);
65
+ }
66
+ });
67
+
68
+ return messages.join(' ') || String(error);
69
+ } catch {
70
+ return String(error);
71
+ }
72
+ };
62
73
 
63
74
  /**
64
75
  * Where the serialised AuthError[] actually lives depends on the next-auth
@@ -71,6 +82,28 @@ const signInErrorDetail = (
71
82
  response: { error?: string | null; code?: string | null } | null | undefined
72
83
  ): string | null | undefined => response?.code || response?.error;
73
84
 
85
+ /**
86
+ * Whether commerce answered with another SMS-verification challenge — which is
87
+ * what a RESEND of a parked registration is supposed to get back, as opposed to
88
+ * the refusals (a rate limit, an expired attempt) that must reach the shopper.
89
+ * Kept local for the same reason `readAuthError` is: exporting register-form's
90
+ * copy would change a shipped module's public surface.
91
+ */
92
+ const hasOtpChallenge = (error: string | null | undefined): boolean => {
93
+ if (!error) return false;
94
+
95
+ try {
96
+ const parsed = JSON.parse(error);
97
+
98
+ return (
99
+ Array.isArray(parsed) &&
100
+ parsed.some((entry) => (entry as { type?: unknown })?.type === 'otp')
101
+ );
102
+ } catch {
103
+ return false;
104
+ }
105
+ };
106
+
74
107
  /**
75
108
  * The OTP endpoint answers a successful send with 2xx and an EMPTY body, which
76
109
  * RTK Query rejects as a parse failure. Brands treat that rejection as the
@@ -141,25 +174,6 @@ const OtpFormBlock = ({
141
174
  getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
142
175
  );
143
176
 
144
- /**
145
- * The modal this form is inside, if any. Read before the destination is
146
- * resolved, because it decides that destination's default: a sign-in inside
147
- * a drawer is a detour from the page the shopper is on, so `stay` is right
148
- * there and `fixed` — the historical push — is right on a page.
149
- */
150
- const hostModalId = useModalHostId();
151
- /**
152
- * Whether finishing closes the modal this form is inside. On here because a
153
- * sign-in leaves nothing to read; the forms whose success state IS the point
154
- * ("check your inbox") default to off. See `useHostModalCloser`.
155
- */
156
- const closeHostModalOnSuccess = readToggle(
157
- block.properties?.closeHostModalOnSuccess,
158
- currentBreakpoint,
159
- true
160
- );
161
- const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
162
-
163
177
  const collectPhone = readToggle(
164
178
  block.properties?.collectPhone,
165
179
  currentBreakpoint,
@@ -217,24 +231,7 @@ const OtpFormBlock = ({
217
231
  const providerId = prop('providerId', 'default');
218
232
  const otpFormType = prop('otpFormType', 'otpLogin');
219
233
  const callbackUrl = prop('callbackUrl', '/');
220
- /**
221
- * Where a finished verification leaves the shopper. Same three modes as the
222
- * Auth Form, and the same default — a phone login inside a drawer wants
223
- * `stay` just as much as an e-mail one does.
224
- */
225
- const afterSignIn = resolveOption<AuthAfterSignIn>(
226
- block.properties?.afterSignIn,
227
- currentBreakpoint,
228
- 'fixed',
229
- AUTH_AFTER_SIGN_IN_MODES as unknown as AuthAfterSignIn[]
230
- );
231
234
  const errorLabel = prop('errorLabel', 'Verification failed.');
232
- // Named rather than shown as raw JSON — see auth-form-block for the reason
233
- // this theme cannot answer the challenge yet.
234
- const captchaLabel = prop(
235
- 'captchaLabel',
236
- 'Please complete the security check and try again.'
237
- );
238
235
  /**
239
236
  * Submitting a half-typed code used to be a silent no-op: the built-in verify
240
237
  * button is disabled until the last box is filled, so nothing could reach it.
@@ -251,17 +248,13 @@ const OtpFormBlock = ({
251
248
  'codeIncompleteMessage',
252
249
  'Enter the {length}-digit code we sent you.'
253
250
  );
254
- /**
255
- * Off now, and the reason it used to be on is what changed: "the commonest
256
- * home for this block is a drawer" is exactly the case the host-scoped close
257
- * below handles, without touching any other overlay. Left on, this toggle
258
- * would take the WIDE branch — every open modal on the page — so a phone
259
- * login would also close an unrelated mini-basket or quick-view.
260
- */
251
+ // Defaults on: the commonest home for this block is a drawer, and a drawer
252
+ // still covering the page the shopper just signed into reads as a hang.
253
+ // closeAllModals is a no-op when nothing is open, so pages pay nothing.
261
254
  const closeModalsOnSuccess = readToggle(
262
255
  block.properties?.closeModalsOnSuccess,
263
256
  currentBreakpoint,
264
- false
257
+ true
265
258
  );
266
259
  // The code phase is unreachable in the canvas without sending a real SMS,
267
260
  // so the designer picks the phase to dress — the previewSuccess pattern.
@@ -273,17 +266,6 @@ const OtpFormBlock = ({
273
266
  );
274
267
 
275
268
  const router = useRouter();
276
- const search = useSearchParams();
277
- const pathname = usePathname();
278
- const destination = resolveAuthDestination({
279
- mode: afterSignIn,
280
- authored: callbackUrl,
281
- search,
282
- // A sign-in inside a drawer is a detour: a destination that names nowhere
283
- // in particular means there is nowhere to go. An authored one still wins.
284
- inModal: Boolean(hostModalId),
285
- pathname
286
- });
287
269
  const { locale, defaultLocaleValue } = useLocalization();
288
270
  const contextFlowId = useContext(AuthFlowIdContext);
289
271
  /**
@@ -524,48 +506,14 @@ const OtpFormBlock = ({
524
506
  });
525
507
 
526
508
  if (!response || response.error) {
527
- // A wrong or expired code is a FIELD refusal — commerce names `code`
528
- // — so it belongs under the boxes the shopper just typed into.
529
- const reading = splitByKnownFields(
530
- readAuthError(signInErrorDetail(response), { captcha: captchaLabel }),
531
- null,
532
- OTP_FIELD_NAMES
533
- );
534
-
535
- setFieldErrors((current) => ({ ...current, ...reading.fieldMessages }));
536
- setError(authFormMessage(reading, errorLabel));
537
-
509
+ setError(readAuthError(signInErrorDetail(response)) || errorLabel);
538
510
  return;
539
511
  }
540
512
 
541
513
  clearPendingOtp(contextFlowId);
542
- /**
543
- * The surface this form finished inside.
544
- *
545
- * Closing the modal it is IN is the default because it is never a
546
- * surprise: a shopper who signed in inside the drawer is done with the
547
- * drawer. `closeModalsOnSuccess` keeps its own, wider meaning — close
548
- * EVERY open modal — for a composition that authored it, which is what a
549
- * form nested two overlays deep needs.
550
- *
551
- * The live GANT drawer is why the default had to change: its OTP form has
552
- * the toggle on and its login and register forms do not, so signing in
553
- * with an e-mail left the drawer covering the page.
554
- */
555
- /**
556
- * Both, not either: a composition that ticked the legacy toggle asked
557
- * for "every open modal", and the wide close is a superset — so the
558
- * narrow one runs unconditionally and simply finds nothing left to do.
559
- * Sequencing them the other way round (an `else`) would have left every
560
- * already-published drawer on the wide branch forever, which is the one
561
- * behaviour this family exists to stop.
562
- */
563
514
  if (closeModalsOnSuccess) closeAllModals();
564
- closeHostModal();
565
515
  router.refresh();
566
- // Our own resolved path rather than next-auth's echo of it; `null` is
567
- // `stay`, where the refresh above is the whole navigation.
568
- if (destination) router.push(destination);
516
+ router.push(response.url || callbackUrl);
569
517
  } catch {
570
518
  setError(errorLabel);
571
519
  } finally {
@@ -602,24 +550,8 @@ const OtpFormBlock = ({
602
550
  // never sent. (Same shape `submitCode` reads.)
603
551
  const detail = signInErrorDetail(response);
604
552
 
605
- const resendReading = readAuthError(detail, {
606
- captcha: captchaLabel
607
- });
608
-
609
- if (response?.error && !resendReading.hasOtpChallenge) {
610
- // The rate limit commerce actually reported, rather than "could not
611
- // send the code" over and over while the shopper retries into a
612
- // lockout. Field-level detail is folded in: there is no field on
613
- // screen to hang it under at this point in the journey.
614
- setError(
615
- [
616
- resendReading.formMessage,
617
- ...Object.values(resendReading.fieldMessages)
618
- ]
619
- .filter(Boolean)
620
- .join(' ') || sendErrorLabel
621
- );
622
-
553
+ if (response?.error && !hasOtpChallenge(detail)) {
554
+ setError(readAuthError(detail) || sendErrorLabel);
623
555
  return;
624
556
  }
625
557
 
@@ -740,17 +672,6 @@ const OtpFormBlock = ({
740
672
  </div>
741
673
  ) : null;
742
674
 
743
- /**
744
- * What the composition's Form State containers read. A verification ends in
745
- * a sign-in, so there is no success SCREEN — what can be scoped is the
746
- * request running and the refusal. See `auth/form-state-context.tsx`.
747
- */
748
- const formStateValue: AuthFormStateValue = {
749
- screen: 'form',
750
- busy: isBusy,
751
- hasError: Boolean(error)
752
- };
753
-
754
675
  /**
755
676
  * Which children this phase draws.
756
677
  *
@@ -761,19 +682,6 @@ const OtpFormBlock = ({
761
682
  * flow applies to its steps.
762
683
  */
763
684
  const showsInPhase = (childBlock: BlockRendererProps['block']): boolean => {
764
- // A state container answers to what the form is DOING rather than to which
765
- // half it is on — a "sending the code…" panel or a designed refusal box —
766
- // so it is filtered by state, not by phase.
767
- if (childBlock.type === 'form-state') {
768
- return showsInFormState({
769
- block: childBlock,
770
- state: formStateValue,
771
- isDesigner,
772
- selectedBlockId,
773
- currentBreakpoint
774
- });
775
- }
776
-
777
685
  if (childBlock.type !== 'otp-phase') return true;
778
686
 
779
687
  if (
@@ -865,7 +773,6 @@ const OtpFormBlock = ({
865
773
  // message is what a Form Error block draws wherever the design put it.
866
774
  <OtpFormProvider value={api}>
867
775
  <AuthFormErrorProvider value={error}>
868
- <AuthFormStateProvider value={formStateValue}>
869
776
  <AuthFieldErrorsProvider value={fieldErrors}>
870
777
  <form
871
778
  id={formHtmlId || undefined}
@@ -895,7 +802,6 @@ const OtpFormBlock = ({
895
802
  {!errorIsAuthored && errorNode}
896
803
  </form>
897
804
  </AuthFieldErrorsProvider>
898
- </AuthFormStateProvider>
899
805
  </AuthFormErrorProvider>
900
806
  </OtpFormProvider>
901
807
  );
@@ -7,7 +7,6 @@ import {
7
7
  useUpdateProfileMutation
8
8
  } from '@akinon/next/data/client/account';
9
9
  import { getResponsiveValue, partAttrs } from '../utils';
10
- import { useHostModalCloser } from '../overlays/modal-host-context';
11
10
  import { BlockRendererProps } from './block-renderer-registry';
12
11
  import { DesignerPlaceholder, readToggle } from './product-block-utils';
13
12
 
@@ -64,17 +63,6 @@ const ProfileFormBlock = ({
64
63
  getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
65
64
  );
66
65
 
67
- /**
68
- * Whether saving closes the modal this form is inside. Off by default: the
69
- * "Saved" confirmation is the only feedback the shopper gets.
70
- */
71
- const closeHostModalOnSuccess = readToggle(
72
- block.properties?.closeHostModalOnSuccess,
73
- currentBreakpoint,
74
- false
75
- );
76
- const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
77
-
78
66
  const submitLabel = text('submitLabel', 'Save changes');
79
67
  const submitAlign = text('submitAlign', 'stretch');
80
68
  const firstNameLabel = text('firstNameLabel', 'First name');
@@ -177,7 +165,6 @@ const ProfileFormBlock = ({
177
165
  }).unwrap();
178
166
 
179
167
  setStatus('saved');
180
- closeHostModal();
181
168
  } catch {
182
169
  setStatus('error');
183
170
  }