@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,13 +1,34 @@
1
1
  'use client';
2
2
 
3
- import React, { useState } from 'react';
3
+ import React, { useContext, useState } from 'react';
4
4
  import NextLink from 'next/link';
5
5
  import { useRouter } from 'next/navigation';
6
6
  import { signIn } from 'next-auth/react';
7
7
  import { useLocalization } from '@akinon/next/hooks';
8
8
  import { getResponsiveValue, partAttrs } from '../utils';
9
+ import {
10
+ AuthFieldErrors,
11
+ AuthFieldErrorsProvider,
12
+ clearAuthFieldError,
13
+ collectAuthFieldErrors
14
+ } from '../auth/auth-field-errors';
15
+ import {
16
+ clearFormSubmitState,
17
+ publishFormSubmitState
18
+ } from '../auth/form-submit-bus';
19
+ import {
20
+ AuthFormErrorProvider,
21
+ containsAuthErrorBlock
22
+ } from '../auth/form-error-context';
9
23
  import { emitCommerceEvent } from '../utils/analytics';
10
24
  import { readToggle } from './product-block-utils';
25
+ import { closeAllModals } from '../overlays/overlay-bus';
26
+ import {
27
+ AuthFlowIdContext,
28
+ resolveAuthStepTarget,
29
+ setAuthStep
30
+ } from '../auth/auth-flow-bus';
31
+ import { renderComposedChildren } from './render-composed-children';
11
32
  import { BlockRendererProps } from './block-renderer-registry';
12
33
 
13
34
  /**
@@ -48,6 +69,36 @@ const readAuthError = (error?: string | null): string => {
48
69
  }
49
70
  };
50
71
 
72
+ /**
73
+ * Names a composed field must not take, because the form and next-auth already
74
+ * mean something by them. A local copy of the register form's set, kept local
75
+ * for the same reason `readAuthError` is: making it an export would change a
76
+ * shipped module's public surface.
77
+ */
78
+ const RESERVED_SUBMIT_KEYS = new Set([
79
+ 'formType',
80
+ 'locale',
81
+ 'code',
82
+ 'redirect',
83
+ 'callbackUrl',
84
+ 'csrfToken',
85
+ 'json'
86
+ ]);
87
+
88
+ /**
89
+ * Where the serialised AuthError[] actually lives depends on the next-auth
90
+ * major. v4 puts it into `error` itself; v5 keeps `error` at the generic
91
+ * "CredentialsSignin" enum name and moves the serialised array onto `code` —
92
+ * which is also what the brands' own views read. Preferring `code` shows the
93
+ * shopper the real commerce message on v5 storefronts instead of the enum.
94
+ */
95
+ const signInErrorDetail = (
96
+ response:
97
+ | { error?: string | null; code?: string | null }
98
+ | null
99
+ | undefined
100
+ ): string | null | undefined => response?.code || response?.error;
101
+
51
102
  /**
52
103
  * Functional auth block: login or register form driven by props.mode.
53
104
  *
@@ -61,13 +112,17 @@ const readAuthError = (error?: string | null): string => {
61
112
  * Properties: mode, submitLabel, busyLabel, providerId, loginFormType,
62
113
  * registerFormType, callbackUrl, emailLabel, passwordLabel, firstNameLabel,
63
114
  * lastNameLabel, the matching *Placeholder keys, showLabels, errorLabel,
64
- * showForgotPassword, forgotPasswordLabel, forgotPasswordUrl.
115
+ * showForgotPassword, forgotPasswordLabel, forgotPasswordUrl,
116
+ * closeModalsOnSuccess.
65
117
  * Parts: form, field, label, input, error, submit, link.
66
118
  */
67
119
  const AuthFormBlock = ({
68
120
  block,
69
- currentBreakpoint = 'desktop',
70
- isDesigner = false
121
+ placeholderId,
122
+ sectionId,
123
+ isDesigner = false,
124
+ selectedBlockId,
125
+ currentBreakpoint = 'desktop'
71
126
  }: BlockRendererProps) => {
72
127
  const prop = (key: string, fallback: string) =>
73
128
  String(
@@ -98,13 +153,55 @@ const AuthFormBlock = ({
98
153
  );
99
154
  const forgotPasswordLabel = prop('forgotPasswordLabel', 'Forgot password?');
100
155
  const forgotPasswordUrl = prop('forgotPasswordUrl', '/auth/forgot-password');
156
+ /**
157
+ * Where "forgot password?" goes when the form lives inside an Auth Flow.
158
+ *
159
+ * The link navigates by default, which is right on an auth PAGE and wrong in
160
+ * a drawer: it closes the journey the shopper was two fields into. Naming a
161
+ * step keeps them inside the flow — put a Forgot Password block in that step.
162
+ * Empty keeps the URL, so every published form behaves as before.
163
+ */
164
+ const forgotPasswordStepTarget = prop('forgotPasswordStepTarget', '').trim();
101
165
  const formType =
102
166
  mode === 'register'
103
167
  ? prop('registerFormType', 'registration')
104
168
  : prop('loginFormType', 'login');
169
+ // Off by default: a form on a plain auth page must not start closing modals
170
+ // it does not live in. Compositions inside a drawer turn it on so a login
171
+ // does not leave the drawer covering the page the shopper just entered.
172
+ const closeModalsOnSuccess = readToggle(
173
+ block.properties?.closeModalsOnSuccess,
174
+ currentBreakpoint,
175
+ false
176
+ );
177
+ /**
178
+ * Detached submit: `formHtmlId` puts an id on the <form> so a Button block
179
+ * anywhere on the page can submit it through the native `form` attribute —
180
+ * which is how a design pins the CTA to a drawer's bottom bar while the
181
+ * fields stay in the flow. `showSubmit` off hides the built-in button then;
182
+ * Enter in a field still submits, as it does in any form.
183
+ */
184
+ /**
185
+ * The copy for a field left empty or filled in wrongly. Editor properties
186
+ * because these are shopper-facing sentences in the brand's own language —
187
+ * replacing the browser's wording is the whole point of validating here.
188
+ */
189
+ const requiredMessage = prop('requiredMessage', 'This field is required.');
190
+ const invalidMessage = prop('invalidMessage', 'Please check this field.');
191
+ const formHtmlId = prop('formHtmlId', '').trim();
192
+ const showSubmit = readToggle(
193
+ block.properties?.showSubmit,
194
+ currentBreakpoint,
195
+ true
196
+ );
105
197
 
106
198
  const router = useRouter();
107
199
  const { locale, defaultLocaleValue } = useLocalization();
200
+ const contextFlowId = useContext(AuthFlowIdContext);
201
+ const forgotPasswordStep = resolveAuthStepTarget(
202
+ forgotPasswordStepTarget,
203
+ contextFlowId
204
+ );
108
205
 
109
206
  const [values, setValues] = useState({
110
207
  email: '',
@@ -113,6 +210,9 @@ const AuthFormBlock = ({
113
210
  last_name: ''
114
211
  });
115
212
  const [error, setError] = useState('');
213
+ // Per FIELD, as opposed to `error` above, which is what the auth backend
214
+ // answered about the attempt as a whole.
215
+ const [fieldErrors, setFieldErrors] = useState<AuthFieldErrors>({});
116
216
  const [isSubmitting, setIsSubmitting] = useState(false);
117
217
 
118
218
  const setField = (key: keyof typeof values) => (
@@ -122,37 +222,114 @@ const AuthFormBlock = ({
122
222
  setValues((current) => ({ ...current, [key]: nextValue }));
123
223
  };
124
224
 
125
- const submit = async (event: React.FormEvent) => {
225
+ /**
226
+ * Composed mode: the form's fields are CHILD blocks (auth-field and
227
+ * whatever the designer arranged between them), so the values live in the
228
+ * DOM, not in this component's state — FormData reads every named input at
229
+ * submit time. That is what makes the field order, the links between
230
+ * fields and the CTA placement free arrangement instead of a fixed layout.
231
+ */
232
+ const childBlocks = (block.blocks || [])
233
+ .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
234
+ .sort((a, b) => (a.order || 0) - (b.order || 0));
235
+ const isComposed = childBlocks.length > 0;
236
+
237
+ /**
238
+ * A message describes what a field WAS: the moment the shopper edits it, it
239
+ * goes. `input` bubbles, so one handler on the form covers every field a
240
+ * composition put inside it, at any depth — and it is wired to `change` as
241
+ * well, for the edits that arrive without keystrokes (browser autofill, a
242
+ * select).
243
+ */
244
+ /**
245
+ * A detached CTA (a Button carrying this form's id) is outside this subtree
246
+ * and cannot see `isSubmitting` — so the form publishes it. See
247
+ * `auth/form-submit-bus.ts`.
248
+ */
249
+ React.useEffect(() => {
250
+ if (!formHtmlId) return undefined;
251
+
252
+ publishFormSubmitState(formHtmlId, isSubmitting);
253
+
254
+ return () => clearFormSubmitState(formHtmlId);
255
+ }, [formHtmlId, isSubmitting]);
256
+
257
+ const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
258
+ const target = event.target as { name?: string } | null;
259
+
260
+ setFieldErrors((current) =>
261
+ clearAuthFieldError(current, target?.name || '')
262
+ );
263
+ };
264
+
265
+ const submit = async (event: React.FormEvent<HTMLFormElement>) => {
126
266
  event.preventDefault();
127
267
  setError('');
128
268
 
129
- if (isDesigner) return;
269
+ if (isDesigner || isSubmitting) return;
270
+
271
+ // Before anything is sent: the form carries `noValidate`, so nothing else
272
+ // is going to stop an empty submit, and nothing else would have told the
273
+ // shopper WHICH field is the problem.
274
+ const form = event.currentTarget;
275
+ const { errors: nextFieldErrors, firstInvalid } = collectAuthFieldErrors(
276
+ form,
277
+ { required: requiredMessage, invalid: invalidMessage }
278
+ );
279
+
280
+ setFieldErrors(nextFieldErrors);
281
+
282
+ if (firstInvalid) {
283
+ firstInvalid.focus();
284
+
285
+ return;
286
+ }
130
287
 
131
288
  const payload: Record<string, string> = {
132
289
  formType,
133
290
  // The core credentials provider looks the locale up in Settings and
134
291
  // throws when it is missing, so an explicit value is always sent.
135
- locale: locale || defaultLocaleValue || '',
136
- email: values.email,
137
- password: values.password
292
+ locale: locale || defaultLocaleValue || ''
138
293
  };
139
294
 
140
- if (mode === 'register') {
141
- payload.first_name = values.first_name;
142
- payload.last_name = values.last_name;
295
+ if (isComposed) {
296
+ new FormData(event.currentTarget).forEach((entryValue, key) => {
297
+ if (typeof entryValue !== 'string') return;
298
+
299
+ // Same three rules the register form applies, and for the same
300
+ // reasons: a field named after part of the protocol would change the
301
+ // request rather than add to it, an answer nobody gave should not
302
+ // travel as an empty string, and where a name is duplicated the FIRST
303
+ // control wins — which is what `collectAuthFieldErrors` already does
304
+ // with the same duplicate.
305
+ if (RESERVED_SUBMIT_KEYS.has(key)) return;
306
+ if (entryValue === '') return;
307
+ if (key in payload) return;
308
+
309
+ payload[key] = entryValue;
310
+ });
311
+ } else {
312
+ payload.email = values.email;
313
+ payload.password = values.password;
314
+
315
+ if (mode === 'register') {
316
+ payload.first_name = values.first_name;
317
+ payload.last_name = values.last_name;
318
+ }
143
319
  }
144
320
 
145
321
  setIsSubmitting(true);
146
322
 
147
323
  try {
148
324
  const response = await signIn(providerId, {
325
+ // After the payload: these decide whether next-auth answers at all.
326
+ ...payload,
149
327
  redirect: false,
150
- callbackUrl,
151
- ...payload
328
+ callbackUrl
152
329
  });
153
330
 
154
331
  if (!response || response.error) {
155
- setError(readAuthError(response?.error) || errorLabel);
332
+ setError(readAuthError(signInErrorDetail(response)) || errorLabel);
156
333
 
157
334
  return;
158
335
  }
@@ -163,6 +340,7 @@ const AuthFormBlock = ({
163
340
  method: providerId
164
341
  });
165
342
 
343
+ if (closeModalsOnSuccess) closeAllModals();
166
344
  router.refresh();
167
345
  router.push(callbackUrl);
168
346
  } catch {
@@ -204,19 +382,195 @@ const AuthFormBlock = ({
204
382
  autoComplete={autoComplete}
205
383
  placeholder={placeholder || undefined}
206
384
  aria-label={showLabels ? undefined : label}
385
+ aria-invalid={fieldErrors[key] ? true : undefined}
207
386
  required
208
387
  {...partAttrs('input')}
209
- style={inputStyle}
388
+ style={{
389
+ ...inputStyle,
390
+ ...(fieldErrors[key]
391
+ ? { borderColor: 'var(--auth-form-border-invalid, #dc2626)' }
392
+ : null)
393
+ }}
210
394
  />
395
+ {fieldErrors[key] && (
396
+ <span
397
+ data-auth-form-field-error={key}
398
+ {...partAttrs('error')}
399
+ role="alert"
400
+ style={{
401
+ color: 'var(--auth-form-error-color, #dc2626)',
402
+ fontSize: '0.875em'
403
+ }}
404
+ >
405
+ {fieldErrors[key]}
406
+ </span>
407
+ )}
211
408
  </label>
212
409
  );
213
410
 
411
+ /**
412
+ * "Forgot password?", in both modes.
413
+ *
414
+ * Composed forms draw it too: the fields being child blocks says nothing
415
+ * about the chrome around them — the submit button and the form-level error
416
+ * are drawn in both modes for the same reason. Left out, `showForgotPassword`
417
+ * and the Link part group meant one thing on a fixed form and nothing at all
418
+ * on a composed one, which is the kind of difference a designer discovers by
419
+ * publishing.
420
+ */
421
+ const forgotPasswordControl =
422
+ showForgotPassword && forgotPasswordLabel ? (
423
+ forgotPasswordStep ? (
424
+ // A button, not a link: this moves the flow, it does not navigate —
425
+ // and `type="button"` keeps it from submitting the form it sits in.
426
+ // Styled to read as the link it replaces.
427
+ <button
428
+ type="button"
429
+ data-auth-form-link="reset-password"
430
+ {...partAttrs('link')}
431
+ onClick={() => {
432
+ // The canvas has its own step switcher; a click here must not move
433
+ // the flow under the designer's feet.
434
+ if (isDesigner) return;
435
+
436
+ setAuthStep(forgotPasswordStep.flowId, forgotPasswordStep.stepKey);
437
+ }}
438
+ style={{
439
+ alignSelf: 'flex-start',
440
+ padding: 0,
441
+ margin: 0,
442
+ border: 'none',
443
+ background: 'transparent',
444
+ color: 'inherit',
445
+ font: 'inherit',
446
+ textDecoration: 'underline',
447
+ cursor: 'pointer'
448
+ }}
449
+ >
450
+ {forgotPasswordLabel}
451
+ </button>
452
+ ) : (
453
+ <NextLink
454
+ data-auth-form-link="reset-password"
455
+ {...partAttrs('link')}
456
+ href={isDesigner ? '#' : forgotPasswordUrl}
457
+ onClick={(event) => {
458
+ if (isDesigner) event.preventDefault();
459
+ }}
460
+ style={{ color: 'inherit' }}
461
+ >
462
+ {forgotPasswordLabel}
463
+ </NextLink>
464
+ )
465
+ ) : null;
466
+
467
+ /**
468
+ * The children, with the six editor callbacks each one needs to be movable,
469
+ * duplicable, hideable, deletable and renamable on the canvas.
470
+ *
471
+ * Through the package's own `renderComposedChildren` rather than a private
472
+ * copy of it. The copies each posted their messages to `'*'`, which
473
+ * `editor-bridge.ts` exists to prevent — it addresses the editor's real
474
+ * origin, so an editor command never reaches whatever other page happens to
475
+ * be embedding the storefront — and every copy was a place a new message
476
+ * field had to be remembered.
477
+ */
478
+ const renderChildren = () =>
479
+ renderComposedChildren({
480
+ blocks: childBlocks,
481
+ placeholderId,
482
+ sectionId,
483
+ isDesigner,
484
+ selectedBlockId,
485
+ currentBreakpoint
486
+ });
487
+
488
+ if (isComposed) {
489
+ /**
490
+ * The composed form: children carry the fields (native inputs named by
491
+ * `auth-field`), submit reads them through FormData. The built-in submit
492
+ * stays available under its toggle so a fresh composition works before
493
+ * the designer wires their own CTA.
494
+ */
495
+ const errorIsAuthored = containsAuthErrorBlock(childBlocks);
496
+
497
+ return (
498
+ <AuthFormErrorProvider value={error}>
499
+ <AuthFieldErrorsProvider value={fieldErrors}>
500
+ <form
501
+ id={formHtmlId || undefined}
502
+ data-auth-form=""
503
+ // Fills its block wrapper; see the note in auth-step-block.tsx.
504
+ className="w-full"
505
+ data-auth-form-mode={mode}
506
+ {...partAttrs('form')}
507
+ onSubmit={submit}
508
+ // The browser's own bubble is what this replaces; the constraints it
509
+ // reads (`required`, `type`) stay on the fields and are still what
510
+ // `collectAuthFieldErrors` reports on.
511
+ noValidate
512
+ onInput={handleInput}
513
+ onChange={handleInput}
514
+ style={{
515
+ display: 'flex',
516
+ flexDirection: 'column',
517
+ gap: 'var(--auth-form-gap, 12px)'
518
+ }}
519
+ >
520
+ {renderChildren()}
521
+
522
+ {forgotPasswordControl}
523
+
524
+ {/* The composition's own Form Error block draws the message where the
525
+ design wants it; this node is the fallback for one that has not
526
+ placed it, and appending it here is exactly why the block exists.
527
+ See `auth/form-error-context.tsx`. */}
528
+ {error && !errorIsAuthored && (
529
+ <div data-auth-form-error="" {...partAttrs('error')} role="alert">
530
+ {error}
531
+ </div>
532
+ )}
533
+
534
+ {showSubmit && (
535
+ <button
536
+ type="submit"
537
+ data-auth-form-submit=""
538
+ {...partAttrs('submit', {
539
+ loading: isSubmitting,
540
+ disabled: isSubmitting
541
+ })}
542
+ disabled={isSubmitting}
543
+ style={{
544
+ padding: '12px 20px',
545
+ background: 'var(--auth-form-cta-bg, #111827)',
546
+ color: 'var(--auth-form-cta-color, #ffffff)',
547
+ border: 'none',
548
+ cursor: 'pointer'
549
+ }}
550
+ >
551
+ {isSubmitting && busyLabel ? busyLabel : submitLabel}
552
+ </button>
553
+ )}
554
+ </form>
555
+ </AuthFieldErrorsProvider>
556
+ </AuthFormErrorProvider>
557
+ );
558
+ }
559
+
214
560
  return (
215
561
  <form
562
+ id={formHtmlId || undefined}
216
563
  data-auth-form=""
564
+ // Fills its block wrapper; see the note in auth-step-block.tsx.
565
+ className="w-full"
217
566
  data-auth-form-mode={mode}
218
567
  {...partAttrs('form')}
219
568
  onSubmit={submit}
569
+ // Same reasoning as the composed form above: our own message under the
570
+ // field, not the browser's bubble over it.
571
+ noValidate
572
+ onInput={handleInput}
573
+ onChange={handleInput}
220
574
  style={{
221
575
  display: 'flex',
222
576
  flexDirection: 'column',
@@ -257,19 +611,7 @@ const AuthFormBlock = ({
257
611
  prop('passwordPlaceholder', '')
258
612
  )}
259
613
 
260
- {showForgotPassword && forgotPasswordLabel && (
261
- <NextLink
262
- data-auth-form-link="reset-password"
263
- {...partAttrs('link')}
264
- href={isDesigner ? '#' : forgotPasswordUrl}
265
- onClick={(event) => {
266
- if (isDesigner) event.preventDefault();
267
- }}
268
- style={{ color: 'inherit' }}
269
- >
270
- {forgotPasswordLabel}
271
- </NextLink>
272
- )}
614
+ {forgotPasswordControl}
273
615
 
274
616
  {error && (
275
617
  <div data-auth-form-error="" {...partAttrs('error')} role="alert">
@@ -277,24 +619,26 @@ const AuthFormBlock = ({
277
619
  </div>
278
620
  )}
279
621
 
280
- <button
281
- type="submit"
282
- data-auth-form-submit=""
283
- {...partAttrs('submit', {
284
- loading: isSubmitting,
285
- disabled: isSubmitting
286
- })}
287
- disabled={isSubmitting}
288
- style={{
289
- padding: '12px 20px',
290
- background: 'var(--auth-form-cta-bg, #111827)',
291
- color: 'var(--auth-form-cta-color, #ffffff)',
292
- border: 'none',
293
- cursor: 'pointer'
294
- }}
295
- >
296
- {isSubmitting && busyLabel ? busyLabel : submitLabel}
297
- </button>
622
+ {showSubmit && (
623
+ <button
624
+ type="submit"
625
+ data-auth-form-submit=""
626
+ {...partAttrs('submit', {
627
+ loading: isSubmitting,
628
+ disabled: isSubmitting
629
+ })}
630
+ disabled={isSubmitting}
631
+ style={{
632
+ padding: '12px 20px',
633
+ background: 'var(--auth-form-cta-bg, #111827)',
634
+ color: 'var(--auth-form-cta-color, #ffffff)',
635
+ border: 'none',
636
+ cursor: 'pointer'
637
+ }}
638
+ >
639
+ {isSubmitting && busyLabel ? busyLabel : submitLabel}
640
+ </button>
641
+ )}
298
642
  </form>
299
643
  );
300
644
  };
@@ -0,0 +1,81 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { getResponsiveValue, partAttrs } from '../utils';
5
+ import { renderComposedChildren } from './render-composed-children';
6
+ import { BlockRendererProps } from './block-renderer-registry';
7
+ import { DesignerPlaceholder } from './product-block-utils';
8
+
9
+ /**
10
+ * One step of an auth journey: a named wrapper whose contents are authored as
11
+ * CHILD blocks — a login form, an OTP form, plain text and buttons, whatever
12
+ * the design says the step is.
13
+ *
14
+ * The step itself decides nothing about visibility. Inside an `auth-flow`
15
+ * block the FLOW renders only the active step; dropped anywhere else the step
16
+ * is just a labelled column and its children always show — which is what makes
17
+ * every step reusable on its own (a register step straight on a page, a login
18
+ * step inside any panel).
19
+ *
20
+ * Properties: stepKey.
21
+ * Parts: step.
22
+ */
23
+ const AuthStepBlock = ({
24
+ block,
25
+ placeholderId,
26
+ sectionId,
27
+ isDesigner = false,
28
+ selectedBlockId,
29
+ currentBreakpoint = 'desktop'
30
+ }: BlockRendererProps) => {
31
+ const stepKey = String(
32
+ getResponsiveValue(block.properties?.stepKey, currentBreakpoint, '')
33
+ ).trim();
34
+
35
+ const childBlocks = (block.blocks || [])
36
+ .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
37
+ .sort((a, b) => (a.order || 0) - (b.order || 0));
38
+
39
+ // An empty step renders nothing a shopper could see; in the canvas it has to
40
+ // say what it is waiting for, or adding one looks like nothing happened.
41
+ if (childBlocks.length === 0 && !isDesigner) return null;
42
+
43
+ return (
44
+ <div
45
+ data-auth-step=""
46
+ // Fills the block wrapper the designer selects.
47
+ //
48
+ // That wrapper is a flex ROW by default (the Visibility control ships
49
+ // `flex`), and this element is its single item — so left to itself it
50
+ // shrink-wraps to its longest line of text, which is why a drawer full of
51
+ // auth blocks came out narrower than the drawer. A CLASS rather than an
52
+ // inline width on purpose: the generated part rule
53
+ // (`[data-block-id="x"] [data-part="…"]`) outranks a class, so a designer
54
+ // who types a width still wins — an inline style would outrank the rule and
55
+ // make that control inert.
56
+ className="w-full"
57
+ data-auth-step-key={stepKey || undefined}
58
+ {...partAttrs('step')}
59
+ style={{
60
+ display: 'flex',
61
+ flexDirection: 'column',
62
+ gap: 'var(--auth-step-gap, 12px)'
63
+ }}
64
+ >
65
+ {childBlocks.length === 0 ? (
66
+ <DesignerPlaceholder label="Auth step (drop its content here)" />
67
+ ) : (
68
+ renderComposedChildren({
69
+ blocks: childBlocks,
70
+ placeholderId,
71
+ sectionId,
72
+ isDesigner,
73
+ selectedBlockId,
74
+ currentBreakpoint
75
+ })
76
+ )}
77
+ </div>
78
+ );
79
+ };
80
+
81
+ export default AuthStepBlock;