@akinon/pz-theme 2.0.83-rc.0 → 2.0.84-beta.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 (63) hide show
  1. package/CHANGELOG.md +39 -33
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-flow-block.test.tsx +211 -0
  4. package/src/__tests__/auth-flow-bus.test.ts +290 -0
  5. package/src/__tests__/auth-form-error-block.test.tsx +326 -0
  6. package/src/__tests__/auth-form-validation.test.tsx +204 -0
  7. package/src/__tests__/auth-step-title.test.tsx +202 -0
  8. package/src/__tests__/detached-submit-state.test.tsx +216 -0
  9. package/src/__tests__/forgot-password-composed.test.tsx +313 -0
  10. package/src/__tests__/otp-form-composed.test.tsx +487 -0
  11. package/src/__tests__/password-reset-step.test.tsx +269 -0
  12. package/src/__tests__/register-form-composed.test.tsx +739 -0
  13. package/src/__tests__/registry-parity.test.ts +9 -0
  14. package/src/auth/auth-field-errors.tsx +151 -0
  15. package/src/auth/auth-flow-bus.ts +415 -0
  16. package/src/auth/form-error-context.tsx +55 -0
  17. package/src/auth/form-submit-bus.ts +93 -0
  18. package/src/auth/otp-form-context.tsx +80 -0
  19. package/src/blocks/__tests__/button-block.test.tsx +55 -0
  20. package/src/blocks/__tests__/link-block.test.tsx +48 -30
  21. package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
  22. package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
  23. package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
  24. package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
  25. package/src/blocks/__tests__/text-block.test.tsx +37 -3
  26. package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
  27. package/src/blocks/auth-consent-block.tsx +214 -0
  28. package/src/blocks/auth-error-block.tsx +82 -0
  29. package/src/blocks/auth-field-block.tsx +270 -0
  30. package/src/blocks/auth-flow-block.tsx +251 -0
  31. package/src/blocks/auth-form-block.tsx +391 -47
  32. package/src/blocks/auth-step-block.tsx +81 -0
  33. package/src/blocks/auth-step-title-block.tsx +81 -0
  34. package/src/blocks/block-renderer-registry.tsx +18 -0
  35. package/src/blocks/button-block.tsx +91 -13
  36. package/src/blocks/forgot-password-block.tsx +318 -24
  37. package/src/blocks/link-block.tsx +22 -84
  38. package/src/blocks/otp-code-input-block.tsx +180 -0
  39. package/src/blocks/otp-form-block.tsx +1092 -0
  40. package/src/blocks/otp-phase-block.tsx +71 -0
  41. package/src/blocks/register-form-block.tsx +541 -74
  42. package/src/blocks/text-block.tsx +17 -40
  43. package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
  44. package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
  45. package/src/hooks/use-block-action.ts +87 -4
  46. package/src/hooks/use-overlay.ts +60 -9
  47. package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
  48. package/src/overlays/__tests__/modal-registry.test.ts +6 -1
  49. package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
  50. package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
  51. package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
  52. package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
  53. package/src/overlays/modal-preview-host.tsx +39 -2
  54. package/src/overlays/modal-registry.ts +344 -4
  55. package/src/overlays/overlay-surface.tsx +249 -19
  56. package/src/overlays/overlay-tokens.ts +94 -0
  57. package/src/overlays/theme-modal-mount.tsx +34 -4
  58. package/src/theme-block.tsx +6 -0
  59. package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
  60. package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
  61. package/src/utils/index.ts +107 -6
  62. package/src/utils/iterator-utils.ts +39 -2
  63. package/src/utils/part-styles.ts +4 -0
@@ -1,11 +1,37 @@
1
1
  'use client';
2
2
 
3
- import React, { useState } from 'react';
3
+ import React, { useContext, useState } from 'react';
4
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 { getResponsiveValue, partAttrs } from '../utils';
8
8
  import { readToggle } from './product-block-utils';
9
+ import { closeAllModals } from '../overlays/overlay-bus';
10
+ import {
11
+ AuthFieldErrors,
12
+ AuthFieldErrorsProvider,
13
+ clearAuthFieldError,
14
+ collectAuthFieldErrors
15
+ } from '../auth/auth-field-errors';
16
+ import {
17
+ AuthFormErrorProvider,
18
+ containsAuthErrorBlock
19
+ } from '../auth/form-error-context';
20
+ import {
21
+ AUTH_CONSENT_NAMES,
22
+ AuthConsentHostProvider
23
+ } from './auth-consent-block';
24
+ import { renderComposedChildren } from './render-composed-children';
25
+ import {
26
+ clearFormSubmitState,
27
+ publishFormSubmitState
28
+ } from '../auth/form-submit-bus';
29
+ import {
30
+ AuthFlowIdContext,
31
+ resolveAuthStepTarget,
32
+ setAuthStep,
33
+ setPendingOtp
34
+ } from '../auth/auth-flow-bus';
9
35
  import { BlockRendererProps } from './block-renderer-registry';
10
36
 
11
37
  /**
@@ -31,9 +57,10 @@ const readAuthError = (
31
57
  const data = (entry as { data?: unknown })?.data;
32
58
 
33
59
  // The OTP branch carries no message: superstep answers a valid
34
- // registration with an `otp` error and expects the storefront to open an
35
- // SMS-verification modal. This block has no modal, so the shopper is at
36
- // least told what happened instead of seeing a silent failure.
60
+ // registration with an `otp` error and expects the storefront to run an
61
+ // SMS-verification step. When no `otpStepTarget` is set the challenge
62
+ // lands here, so the shopper is at least told what happened instead of
63
+ // seeing a silent failure.
37
64
  if (type === 'otp') {
38
65
  messages.push(otpMessage);
39
66
  return;
@@ -59,6 +86,65 @@ const readAuthError = (
59
86
  }
60
87
  };
61
88
 
89
+ /**
90
+ * Names a composed field must not take, because the form and next-auth already
91
+ * mean something by them.
92
+ *
93
+ * A Custom field carries whatever key the brand's backend expects, which is the
94
+ * point of it — but the sign-up body is not only the shopper's answers: it also
95
+ * says which commerce endpoint to call (`formType`), which language to resolve
96
+ * (`locale`), and how next-auth should behave (`redirect`, `callbackUrl`,
97
+ * `csrfToken`, `json`). A field named after one of those would replace it, and
98
+ * `redirect` in particular is not a broken field but a broken FORM: any string
99
+ * is truthy, so next-auth navigates away instead of handing back the response
100
+ * this block reads its errors from.
101
+ */
102
+ const RESERVED_SUBMIT_KEYS = new Set([
103
+ 'formType',
104
+ 'locale',
105
+ 'code',
106
+ 'redirect',
107
+ 'callbackUrl',
108
+ 'csrfToken',
109
+ 'json'
110
+ ]);
111
+
112
+ /**
113
+ * Whether commerce answered a valid registration with an SMS-verification
114
+ * challenge. Detected separately from the message extraction because the
115
+ * challenge is a HANDOFF, not a failure: the pending payload moves to the
116
+ * auth bus and an `otp-form` block finishes the sign-up.
117
+ */
118
+ const hasOtpChallenge = (error: string | null | undefined): boolean => {
119
+ if (!error) return false;
120
+
121
+ try {
122
+ const parsed = JSON.parse(error);
123
+
124
+ return (
125
+ Array.isArray(parsed) &&
126
+ parsed.some((entry) => (entry as { type?: unknown })?.type === 'otp')
127
+ );
128
+ } catch {
129
+ return false;
130
+ }
131
+ };
132
+
133
+ /**
134
+ * Where the serialised AuthError[] actually lives depends on the next-auth
135
+ * major. v4 puts it into `error` itself; v5 keeps `error` at the generic
136
+ * "CredentialsSignin" enum name and moves the serialised array onto `code` —
137
+ * which is also what the brands' own views read. Preferring `code` is what
138
+ * lets both the message extraction and the OTP-challenge detection work on
139
+ * v5 storefronts.
140
+ */
141
+ const signInErrorDetail = (
142
+ response:
143
+ | { error?: string | null; code?: string | null }
144
+ | null
145
+ | undefined
146
+ ): string | null | undefined => response?.code || response?.error;
147
+
62
148
  /**
63
149
  * The commerce registration endpoint is reached through next-auth's credentials
64
150
  * provider, exactly like the brand's own register view: the provider id is
@@ -70,19 +156,28 @@ const FORM_TYPE = 'registration';
70
156
  /**
71
157
  * Functional auth block: full registration form (name, surname, e-mail,
72
158
  * password, optional phone, consents) submitted through the v1 register path.
73
- * Behaviour is fixed (commerce sign-up); the editor owns the labels, the phone
74
- * field and the consent wording.
159
+ *
160
+ * Composable, the same way the Auth Form is: with children the form draws THEM
161
+ * — an `auth-field` per input, an `auth-consent` per tick, the CTA a Button,
162
+ * the message a Form Error block — and reads the values back out of `FormData`
163
+ * on submit. Left empty it renders the fixed rows exactly as before, so a
164
+ * published composition is untouched. Either way the behaviour is the same
165
+ * commerce sign-up: what composes is the layout, not the journey.
75
166
  * Properties: submitLabel, busyLabel, showPhone, showLabels, firstNameLabel,
76
167
  * lastNameLabel, emailLabel, passwordLabel, phoneLabel, the matching
77
168
  * *Placeholder keys, consentText, marketingConsentText, showMarketingConsent,
78
- * consentRequiredLabel, errorLabel, otpLabel.
169
+ * consentRequiredLabel, errorLabel, otpLabel, otpStepTarget,
170
+ * closeModalsOnSuccess.
79
171
  * Parts: form, field, label, input, consent, checkbox, consent-text, error,
80
172
  * submit.
81
173
  */
82
174
  const RegisterFormBlock = ({
83
175
  block,
176
+ placeholderId,
177
+ sectionId,
84
178
  currentBreakpoint = 'desktop',
85
- isDesigner = false
179
+ isDesigner = false,
180
+ selectedBlockId
86
181
  }: BlockRendererProps) => {
87
182
  const prop = (key: string, fallback: string) =>
88
183
  String(
@@ -93,7 +188,11 @@ const RegisterFormBlock = ({
93
188
  // Empty on purpose: without a busy label the submit caption stays put while
94
189
  // the request runs, which is what published blocks render today.
95
190
  const busyLabel = prop('busyLabel', '');
96
- const showPhone = readToggle(block.properties?.showPhone, currentBreakpoint, true);
191
+ const showPhone = readToggle(
192
+ block.properties?.showPhone,
193
+ currentBreakpoint,
194
+ true
195
+ );
97
196
  const showLabels = readToggle(
98
197
  block.properties?.showLabels,
99
198
  currentBreakpoint,
@@ -112,6 +211,13 @@ const RegisterFormBlock = ({
112
211
  'marketingConsentText',
113
212
  'I would like to receive campaign and marketing messages.'
114
213
  );
214
+ /**
215
+ * The copy for a field left empty or filled in wrongly — the brand's own
216
+ * sentence in place of the browser's bubble. See `auth-field-errors.tsx` for
217
+ * why the form validates instead of letting the browser do it.
218
+ */
219
+ const requiredMessage = prop('requiredMessage', 'This field is required.');
220
+ const invalidMessage = prop('invalidMessage', 'Please check this field.');
115
221
  const consentRequiredLabel = prop(
116
222
  'consentRequiredLabel',
117
223
  'Please accept the membership agreement to continue.'
@@ -121,9 +227,44 @@ const RegisterFormBlock = ({
121
227
  'otpLabel',
122
228
  'A verification code was sent. Please confirm it to finish.'
123
229
  );
230
+ // Where the SMS-verification challenge sends the shopper: a step key (or
231
+ // `flow:step`) whose step holds an `otp-form` block. Empty keeps the old
232
+ // behaviour — the otpLabel message — so published compositions are
233
+ // untouched.
234
+ const otpStepTarget = prop('otpStepTarget', '');
235
+ // Off by default: a register form on a plain page must not start closing
236
+ // modals it does not live in. Compositions inside a drawer turn it on.
237
+ const closeModalsOnSuccess = readToggle(
238
+ block.properties?.closeModalsOnSuccess,
239
+ currentBreakpoint,
240
+ false
241
+ );
242
+ /**
243
+ * Detached submit — same contract as auth-form-block: `formHtmlId` names
244
+ * the <form> so an external Button (native `form` attribute) can submit it
245
+ * from a pinned bottom bar; `showSubmit` off hides the built-in button.
246
+ */
247
+ const formHtmlId = prop('formHtmlId', '').trim();
248
+ const showSubmit = readToggle(
249
+ block.properties?.showSubmit,
250
+ currentBreakpoint,
251
+ true
252
+ );
124
253
 
125
254
  const router = useRouter();
126
255
  const { locale, defaultLocaleValue } = useLocalization();
256
+ const contextFlowId = useContext(AuthFlowIdContext);
257
+
258
+ /**
259
+ * The children a composition authored. Hidden ones are dropped for a shopper
260
+ * and kept in the canvas, so an author can dress a row they have switched
261
+ * off — the same rule every composed block here follows.
262
+ */
263
+ const childBlocks = (block.blocks || [])
264
+ .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
265
+ .sort((a, b) => (a.order || 0) - (b.order || 0));
266
+ const isComposed = childBlocks.length > 0;
267
+ const errorIsAuthored = containsAuthErrorBlock(childBlocks);
127
268
 
128
269
  const [values, setValues] = useState({
129
270
  first_name: '',
@@ -135,23 +276,75 @@ const RegisterFormBlock = ({
135
276
  const [legalConsent, setLegalConsent] = useState(false);
136
277
  const [marketingConsent, setMarketingConsent] = useState(false);
137
278
  const [error, setError] = useState('');
279
+ // Per FIELD, as opposed to `error`, which is what the backend answered about
280
+ // the attempt as a whole.
281
+ const [fieldErrors, setFieldErrors] = useState<AuthFieldErrors>({});
138
282
  const [isSubmitting, setIsSubmitting] = useState(false);
139
283
 
140
- const setField =
141
- (key: keyof typeof values) => (event: React.ChangeEvent<HTMLInputElement>) => {
142
- const nextValue = event.target.value;
143
- setValues((current) => ({ ...current, [key]: nextValue }));
144
- };
284
+ const setField = (key: keyof typeof values) => (
285
+ event: React.ChangeEvent<HTMLInputElement>
286
+ ) => {
287
+ const nextValue = event.target.value;
288
+ setValues((current) => ({ ...current, [key]: nextValue }));
289
+ };
290
+
291
+ /**
292
+ * A message describes what a field WAS: it goes the moment that field is
293
+ * edited. Both events, because a keystroke raises `input` while autofill and
294
+ * a checkbox raise `change`.
295
+ */
296
+ /**
297
+ * A detached CTA (a Button carrying this form's id) is outside this subtree
298
+ * and cannot see `isSubmitting` — so the form publishes it. See
299
+ * `auth/form-submit-bus.ts`.
300
+ */
301
+ React.useEffect(() => {
302
+ if (!formHtmlId) return undefined;
303
+
304
+ publishFormSubmitState(formHtmlId, isSubmitting);
305
+
306
+ return () => clearFormSubmitState(formHtmlId);
307
+ }, [formHtmlId, isSubmitting]);
308
+
309
+ const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
310
+ const target = event.target as { name?: string } | null;
311
+
312
+ setFieldErrors((current) =>
313
+ clearAuthFieldError(current, target?.name || '')
314
+ );
315
+ };
145
316
 
146
- const submit = async (event: React.FormEvent) => {
317
+ const submit = async (event: React.FormEvent<HTMLFormElement>) => {
147
318
  event.preventDefault();
148
319
  setError('');
149
320
 
150
321
  // Submitting inside the designer would create a real commerce account.
151
322
  if (isDesigner) return;
152
323
 
153
- if (!legalConsent) {
154
- setError(consentRequiredLabel);
324
+ // Held onto because `currentTarget` is only the form for as long as the
325
+ // handler is on the stack, and the consent question below is asked of the
326
+ // markup rather than of state.
327
+ const formElement = event.currentTarget;
328
+
329
+ // The form carries `noValidate`, so this is what stops an empty submit —
330
+ // and unlike the browser's bubble it says which field, in the brand's
331
+ // words, under the field, until the field is fixed.
332
+ const { errors: nextFieldErrors, firstInvalid } = collectAuthFieldErrors(
333
+ event.currentTarget,
334
+ { required: requiredMessage, invalid: invalidMessage }
335
+ );
336
+
337
+ // The consent tick has a sentence of its own, and it is a better one than
338
+ // "this field is required" for a legal opt-in.
339
+ const withConsentCopy = nextFieldErrors.is_allowed
340
+ ? { ...nextFieldErrors, is_allowed: consentRequiredLabel }
341
+ : nextFieldErrors;
342
+
343
+ setFieldErrors(withConsentCopy);
344
+
345
+ if (firstInvalid) {
346
+ firstInvalid.focus();
347
+
155
348
  return;
156
349
  }
157
350
 
@@ -159,45 +352,173 @@ const RegisterFormBlock = ({
159
352
  formType: FORM_TYPE,
160
353
  // The credentials provider looks the locale up in Settings and throws
161
354
  // when it is missing, so an explicit value is always sent.
162
- locale: locale || defaultLocaleValue || '',
163
- first_name: values.first_name,
164
- last_name: values.last_name,
165
- email: values.email,
166
- password: values.password,
167
- // Commerce requires both consent flags; one checkbox gates them because
168
- // both are mandatory to register at all.
169
- is_allowed: true,
170
- confirm: true,
171
- email_allowed: marketingConsent,
172
- sms_allowed: marketingConsent,
173
- call_allowed: marketingConsent,
174
- whatsapp_allowed: marketingConsent
355
+ locale: locale || defaultLocaleValue || ''
175
356
  };
176
357
 
177
- if (showPhone) {
178
- // Commerce expects bare digits; the mask characters brands render around
179
- // the phone input would fail its length validation.
358
+ /**
359
+ * Who answered the two consent questions: the composition's own
360
+ * `auth-consent` blocks, or this block's fixed rows.
361
+ *
362
+ * A checkbox that is not ticked is simply absent from `FormData` — that is
363
+ * the browser's own answer to "did they agree", so it is read rather than
364
+ * mirrored into state. The names come from the consent block so a
365
+ * composition and a fixed form post the same body.
366
+ */
367
+ let agreedToTerms = legalConsent;
368
+ let agreedToMarketing = marketingConsent;
369
+
370
+ if (isComposed) {
371
+ const entries = new FormData(event.currentTarget);
372
+
373
+ entries.forEach((entryValue, key) => {
374
+ if (typeof entryValue !== 'string') return;
375
+
376
+ // A field the author named after part of the protocol would not be an
377
+ // extra answer, it would be a different request: `formType` decides
378
+ // which commerce endpoint is called and `redirect` decides whether
379
+ // next-auth returns a response at all. The brand's own field is
380
+ // dropped rather than allowed to redirect the sign-up.
381
+ if (RESERVED_SUBMIT_KEYS.has(key)) return;
382
+
383
+ // An answer the shopper did not give is an answer the body should not
384
+ // carry: a gender left on its empty option and an untouched date input
385
+ // both post `''`, which is neither a choice nor a date. The fixed rows
386
+ // never sent these keys, and the block already deletes `permissions`
387
+ // rather than send a falsy one.
388
+ if (entryValue === '') return;
389
+
390
+ // First one wins per name, which is the rule `collectAuthFieldErrors`
391
+ // already applies to the same duplicates — a field the author copied,
392
+ // or a breakpoint twin, left empty. Taking the LAST meant the empty
393
+ // twin erased what the shopper actually typed.
394
+ if (key in payload) return;
395
+
396
+ payload[key] = entryValue;
397
+ });
398
+
399
+ agreedToTerms = entries.get(AUTH_CONSENT_NAMES.legal) !== null;
400
+ agreedToMarketing = entries.get(AUTH_CONSENT_NAMES.marketing) !== null;
401
+
402
+ // The ticks are re-expressed as the flags below, so the raw checkbox
403
+ // entries must not also travel — `is_allowed: 'on'` is not what commerce
404
+ // reads, and `permissions` has to be absent rather than falsy.
405
+ delete payload[AUTH_CONSENT_NAMES.legal];
406
+ delete payload[AUTH_CONSENT_NAMES.marketing];
407
+ } else {
408
+ payload.first_name = values.first_name;
409
+ payload.last_name = values.last_name;
410
+ payload.email = values.email;
411
+ payload.password = values.password;
412
+ }
413
+
414
+ /**
415
+ * Whether the agreement is a question this composition actually asks, and
416
+ * whether it insists on a yes.
417
+ *
418
+ * The refusal used to be unconditional, which made two authored states
419
+ * impossible rather than optional: a Consent Row with Required turned off
420
+ * still could not be left unticked (the editor's toggle says "turn it off
421
+ * for an optional tick", and the block's own docstring promises a brand in
422
+ * a market where the agreement is not mandatory can say so), and a
423
+ * composition with no consent row at all could never submit — the shopper
424
+ * was told to accept an agreement that was nowhere on screen.
425
+ *
426
+ * No row means the brand is not asking, which is what the fixed rows do
427
+ * for a childless form, so the flag goes out the way it always has.
428
+ */
429
+ const legalControl = isComposed
430
+ ? (formElement.querySelector(
431
+ `input[name="${AUTH_CONSENT_NAMES.legal}"]`
432
+ ) as HTMLInputElement | null)
433
+ : null;
434
+ const legalIsMandatory = isComposed
435
+ ? Boolean(legalControl?.required)
436
+ : true;
437
+
438
+ if (legalIsMandatory && !agreedToTerms) {
439
+ setError(consentRequiredLabel);
440
+ return;
441
+ }
442
+
443
+ // Commerce requires both consent flags; one tick gates them because both
444
+ // are mandatory to register at all — unless the composition made the tick
445
+ // optional, and then the shopper's actual answer is what travels rather
446
+ // than a yes nobody gave.
447
+ payload.is_allowed = legalControl ? agreedToTerms : true;
448
+ payload.confirm = payload.is_allowed;
449
+ payload.email_allowed = agreedToMarketing;
450
+ payload.sms_allowed = agreedToMarketing;
451
+ payload.call_allowed = agreedToMarketing;
452
+ payload.whatsapp_allowed = agreedToMarketing;
453
+
454
+ // Commerce expects bare digits; the mask characters brands render around
455
+ // the phone input would fail its length validation.
456
+ if (isComposed) {
457
+ if (typeof payload.phone === 'string') {
458
+ payload.phone = payload.phone.replace(/[^\d]/g, '');
459
+ }
460
+ } else if (showPhone) {
180
461
  payload.phone = values.phone.replace(/[^\d]/g, '');
181
462
  }
182
463
 
183
464
  // `permissions` is only meaningful when opted in — the brand deletes the
184
465
  // key entirely rather than sending a falsy value.
185
- if (marketingConsent) payload.permissions = 'on';
466
+ if (agreedToMarketing) payload.permissions = 'on';
186
467
 
187
468
  setIsSubmitting(true);
188
469
 
189
470
  try {
190
471
  const response = await signIn(PROVIDER_ID, {
472
+ // After the payload, not before it: these decide whether next-auth
473
+ // hands back a response or navigates away, and a body key must never
474
+ // be able to answer that question. `RESERVED_SUBMIT_KEYS` already
475
+ // keeps them out of the payload; this is the belt to that braces.
476
+ ...payload,
191
477
  redirect: false,
192
- callbackUrl: '/',
193
- ...payload
478
+ callbackUrl: '/'
194
479
  });
195
480
 
196
481
  if (!response || response.error) {
197
- setError(readAuthError(response?.error, otpLabel) || errorLabel);
482
+ const errorDetail = signInErrorDetail(response);
483
+
484
+ if (hasOtpChallenge(errorDetail)) {
485
+ // Resolved BEFORE the handoff is parked, so the record can say which
486
+ // journey it belongs to.
487
+ const resolvedStep = resolveAuthStepTarget(
488
+ otpStepTarget,
489
+ contextFlowId
490
+ );
491
+
492
+ // Commerce accepted the sign-up and wants an SMS code. The full
493
+ // payload is parked on the auth bus so an `otp-form` block — in the
494
+ // targeted step, or anywhere on the page — can re-submit it with
495
+ // the code and finish what this form started.
496
+ //
497
+ // Scoped, because the payload holds a password and the page has one
498
+ // slot: without it a header's phone-login drawer, opened later,
499
+ // would find this record, open on its code screen and re-post
500
+ // someone else's registration. `stepKey` is what lets navigating the
501
+ // flow elsewhere abandon a journey nobody came back to.
502
+ setPendingOtp({
503
+ kind: 'register',
504
+ phone: typeof payload.phone === 'string' ? payload.phone : '',
505
+ providerId: PROVIDER_ID,
506
+ payload,
507
+ scope: resolvedStep ? resolvedStep.flowId : contextFlowId,
508
+ stepKey: resolvedStep?.stepKey
509
+ });
510
+
511
+ if (resolvedStep) {
512
+ setAuthStep(resolvedStep.flowId, resolvedStep.stepKey);
513
+ return;
514
+ }
515
+ }
516
+
517
+ setError(readAuthError(errorDetail, otpLabel) || errorLabel);
198
518
  return;
199
519
  }
200
520
 
521
+ if (closeModalsOnSuccess) closeAllModals();
201
522
  router.refresh();
202
523
  router.push(response.url || '/');
203
524
  } catch {
@@ -239,10 +560,29 @@ const RegisterFormBlock = ({
239
560
  autoComplete={autoComplete}
240
561
  placeholder={placeholder || undefined}
241
562
  aria-label={showLabels ? undefined : label}
563
+ aria-invalid={fieldErrors[key] ? true : undefined}
242
564
  required
243
565
  {...partAttrs('input')}
244
- style={inputStyle}
566
+ style={{
567
+ ...inputStyle,
568
+ ...(fieldErrors[key]
569
+ ? { borderColor: 'var(--register-form-border-invalid, #dc2626)' }
570
+ : null)
571
+ }}
245
572
  />
573
+ {fieldErrors[key] && (
574
+ <span
575
+ data-register-form-field-error={key}
576
+ {...partAttrs('error')}
577
+ role="alert"
578
+ style={{
579
+ color: 'var(--register-form-error-color, #dc2626)',
580
+ fontSize: '0.875em'
581
+ }}
582
+ >
583
+ {fieldErrors[key]}
584
+ </span>
585
+ )}
246
586
  </label>
247
587
  );
248
588
 
@@ -253,29 +593,154 @@ const RegisterFormBlock = ({
253
593
  name: string,
254
594
  required: boolean
255
595
  ) => (
256
- <label
257
- data-register-form-consent={name}
258
- {...partAttrs('consent')}
259
- style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}
260
- >
261
- <input
262
- type="checkbox"
263
- name={name}
264
- checked={checked}
265
- onChange={(event) => onChange(event.target.checked)}
266
- required={required}
267
- {...partAttrs('checkbox', { checked })}
268
- style={{ marginTop: '3px' }}
269
- />
270
- <span {...partAttrs('consent-text')}>{label}</span>
271
- </label>
596
+ <>
597
+ <label
598
+ data-register-form-consent={name}
599
+ {...partAttrs('consent')}
600
+ style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}
601
+ >
602
+ <input
603
+ type="checkbox"
604
+ name={name}
605
+ checked={checked}
606
+ onChange={(event) => onChange(event.target.checked)}
607
+ aria-invalid={fieldErrors[name] ? true : undefined}
608
+ required={required}
609
+ {...partAttrs('checkbox', { checked })}
610
+ style={{ marginTop: '3px' }}
611
+ />
612
+ <span {...partAttrs('consent-text')}>{label}</span>
613
+ </label>
614
+ {/* Outside the label rather than inside it: the row is a checkbox beside
615
+ its text, and a message wedged in there would sit between them. */}
616
+ {fieldErrors[name] && (
617
+ <span
618
+ data-register-form-field-error={name}
619
+ {...partAttrs('error')}
620
+ role="alert"
621
+ style={{
622
+ color: 'var(--register-form-error-color, #dc2626)',
623
+ fontSize: '0.875em'
624
+ }}
625
+ >
626
+ {fieldErrors[name]}
627
+ </span>
628
+ )}
629
+ </>
272
630
  );
273
631
 
632
+ /**
633
+ * The children, with the six editor callbacks each one needs to be movable,
634
+ * duplicable, hideable, deletable and renamable on the canvas.
635
+ *
636
+ * Through the package's own `renderComposedChildren` rather than a private
637
+ * copy of it. The copies each posted their messages to `'*'`, which
638
+ * `editor-bridge.ts` exists to prevent — it addresses the editor's real
639
+ * origin, so an editor command never reaches whatever other page happens to
640
+ * be embedding the storefront — and every copy was a place a new message
641
+ * field had to be remembered.
642
+ */
643
+ const renderChildren = () =>
644
+ renderComposedChildren({
645
+ blocks: childBlocks,
646
+ placeholderId,
647
+ sectionId,
648
+ isDesigner,
649
+ selectedBlockId,
650
+ currentBreakpoint
651
+ });
652
+
653
+ if (isComposed) {
654
+ /**
655
+ * The composed form: the children carry the inputs (`auth-field`), the
656
+ * ticks (`auth-consent`) and whatever chrome the design wants around them,
657
+ * and submit reads the lot back out of `FormData`. The built-in button
658
+ * stays available under its toggle so a fresh composition works before the
659
+ * designer has wired their own CTA.
660
+ */
661
+ return (
662
+ <AuthFormErrorProvider value={error}>
663
+ {/* This form is the one that ANSWERS the two known ticks — turning
664
+ them into the flags commerce names individually — so its children
665
+ are where a membership or marketing row actually works. */}
666
+ <AuthConsentHostProvider value>
667
+ <AuthFieldErrorsProvider value={fieldErrors}>
668
+ <form
669
+ id={formHtmlId || undefined}
670
+ data-register-form=""
671
+ data-register-form-composed=""
672
+ // Fills its block wrapper; see the note in auth-step-block.tsx.
673
+ className="w-full"
674
+ {...partAttrs('form')}
675
+ onSubmit={submit}
676
+ // Our own message under the field instead of the browser's bubble
677
+ // over it; the constraints it reads stay on the controls.
678
+ noValidate
679
+ onInput={handleInput}
680
+ onChange={handleInput}
681
+ style={{
682
+ display: 'flex',
683
+ flexDirection: 'column',
684
+ gap: 'var(--register-form-gap, 12px)'
685
+ }}
686
+ >
687
+ {renderChildren()}
688
+
689
+ {/* The fallback for a composition that has not placed a Form Error
690
+ block; see `auth/form-error-context.tsx` for why placing one is
691
+ better than this. */}
692
+ {error && !errorIsAuthored && (
693
+ <div
694
+ data-register-form-error=""
695
+ {...partAttrs('error')}
696
+ role="alert"
697
+ >
698
+ {error}
699
+ </div>
700
+ )}
701
+
702
+ {showSubmit && (
703
+ <button
704
+ type="submit"
705
+ data-register-form-submit=""
706
+ {...partAttrs('submit', {
707
+ loading: isSubmitting,
708
+ disabled: isSubmitting
709
+ })}
710
+ disabled={isSubmitting}
711
+ aria-busy={isSubmitting}
712
+ style={{
713
+ padding: '12px 20px',
714
+ background: 'var(--register-form-cta-bg, #111827)',
715
+ color: 'var(--register-form-cta-color, #ffffff)',
716
+ border: 'none',
717
+ cursor: isSubmitting ? 'not-allowed' : 'pointer',
718
+ opacity: isSubmitting ? 0.7 : 1
719
+ }}
720
+ >
721
+ {isSubmitting && busyLabel ? busyLabel : submitLabel}
722
+ </button>
723
+ )}
724
+ </form>
725
+ </AuthFieldErrorsProvider>
726
+ </AuthConsentHostProvider>
727
+ </AuthFormErrorProvider>
728
+ );
729
+ }
730
+
274
731
  return (
275
732
  <form
733
+ id={formHtmlId || undefined}
276
734
  data-register-form=""
735
+ // Fills its block wrapper; see the note in auth-step-block.tsx.
736
+ className="w-full"
277
737
  {...partAttrs('form')}
278
738
  onSubmit={submit}
739
+ // Our own message under the field instead of the browser's bubble over
740
+ // it; the constraints it reads stay on the controls.
741
+ noValidate
742
+ onInput={handleInput}
743
+ onChange={handleInput}
279
744
  style={{
280
745
  display: 'flex',
281
746
  flexDirection: 'column',
@@ -335,26 +800,28 @@ const RegisterFormBlock = ({
335
800
  </div>
336
801
  )}
337
802
 
338
- <button
339
- type="submit"
340
- data-register-form-submit=""
341
- {...partAttrs('submit', {
342
- loading: isSubmitting,
343
- disabled: isSubmitting
344
- })}
345
- disabled={isSubmitting}
346
- aria-busy={isSubmitting}
347
- style={{
348
- padding: '12px 20px',
349
- background: 'var(--register-form-cta-bg, #111827)',
350
- color: 'var(--register-form-cta-color, #ffffff)',
351
- border: 'none',
352
- cursor: isSubmitting ? 'not-allowed' : 'pointer',
353
- opacity: isSubmitting ? 0.7 : 1
354
- }}
355
- >
356
- {isSubmitting && busyLabel ? busyLabel : submitLabel}
357
- </button>
803
+ {showSubmit && (
804
+ <button
805
+ type="submit"
806
+ data-register-form-submit=""
807
+ {...partAttrs('submit', {
808
+ loading: isSubmitting,
809
+ disabled: isSubmitting
810
+ })}
811
+ disabled={isSubmitting}
812
+ aria-busy={isSubmitting}
813
+ style={{
814
+ padding: '12px 20px',
815
+ background: 'var(--register-form-cta-bg, #111827)',
816
+ color: 'var(--register-form-cta-color, #ffffff)',
817
+ border: 'none',
818
+ cursor: isSubmitting ? 'not-allowed' : 'pointer',
819
+ opacity: isSubmitting ? 0.7 : 1
820
+ }}
821
+ >
822
+ {isSubmitting && busyLabel ? busyLabel : submitLabel}
823
+ </button>
824
+ )}
358
825
  </form>
359
826
  );
360
827
  };