@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
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { act, fireEvent, render, waitFor } from '@testing-library/react';
3
3
  import StoreLocatorBlock from '../store-locator-block';
4
4
  import { ThemePageContextProvider } from '../../theme-page-context';
5
+ import { isScrollLocked } from '../../hooks/use-overlay';
5
6
 
6
7
  /**
7
8
  * The store list is the block a brand already has on the page, and the find-in-
@@ -265,6 +266,7 @@ describe('store locator — find in store is a dialog', () => {
265
266
  expect(trigger(container).getAttribute('aria-haspopup')).toBe('dialog');
266
267
  expect(trigger(container).getAttribute('aria-expanded')).toBe('false');
267
268
  expect(panel(container)).toBeNull();
269
+ expect(isScrollLocked()).toBe(false);
268
270
  expect(document.body.style.overflow).toBe('');
269
271
  });
270
272
 
@@ -281,7 +283,7 @@ describe('store locator — find in store is a dialog', () => {
281
283
  expect(dialog.getAttribute('aria-label')).toBe('Find in store');
282
284
  expect(trigger(container).getAttribute('aria-expanded')).toBe('true');
283
285
  expect(dialog.contains(document.activeElement)).toBe(true);
284
- expect(document.body.style.overflow).toBe('hidden');
286
+ expect(isScrollLocked()).toBe(true);
285
287
  });
286
288
 
287
289
  it('holds Tab inside the dialog', () => {
@@ -335,6 +337,7 @@ describe('store locator — find in store is a dialog', () => {
335
337
 
336
338
  expect(panel(container)).toBeNull();
337
339
  expect(document.activeElement).toBe(trigger(container));
340
+ expect(isScrollLocked()).toBe(false);
338
341
  expect(document.body.style.overflow).toBe('');
339
342
  });
340
343
 
@@ -368,14 +371,15 @@ describe('store locator — find in store is a dialog', () => {
368
371
 
369
372
  act(() => trigger(a.container).click());
370
373
  act(() => trigger(b.container).click());
371
- expect(document.body.style.overflow).toBe('hidden');
374
+ expect(isScrollLocked()).toBe(true);
372
375
 
373
376
  b.unmount();
374
377
  // The first panel is still over the page, so the lock is not the second
375
378
  // panel's to release.
376
- expect(document.body.style.overflow).toBe('hidden');
379
+ expect(isScrollLocked()).toBe(true);
377
380
 
378
381
  a.unmount();
382
+ expect(isScrollLocked()).toBe(false);
379
383
  expect(document.body.style.overflow).toBe('');
380
384
  });
381
385
 
@@ -424,7 +428,7 @@ describe('store locator — find in store is a dialog', () => {
424
428
  // Escape backed out of one panel, not out of every panel on the page — so
425
429
  // the page stays frozen behind the one still covering it, and that one
426
430
  // takes the claim back now that it is on top.
427
- expect(document.body.style.overflow).toBe('hidden');
431
+ expect(isScrollLocked()).toBe(true);
428
432
  expect(
429
433
  (panel(a.container) as HTMLElement).getAttribute('aria-modal')
430
434
  ).toBe('true');
@@ -434,19 +438,41 @@ describe('store locator — find in store is a dialog', () => {
434
438
  });
435
439
 
436
440
  expect(panel(a.container)).toBeNull();
441
+ expect(isScrollLocked()).toBe(false);
437
442
  expect(document.body.style.overflow).toBe('');
438
443
  });
439
444
 
440
- it('restores the page value the first locker found, not an empty one', () => {
445
+ it('never takes the page value it found, so clip stays clip throughout', () => {
446
+ // Each block used to save `body.style.overflow`, overwrite it and put it
447
+ // back, so whatever the page had declared was only ever as safe as the
448
+ // block that borrowed it: the SECOND panel to open saved `hidden` as "the
449
+ // page's own value" and the page never got its own back. Two panels is
450
+ // what it takes to reproduce that, which is why both are here. The lock is
451
+ // an author rule keyed off an attribute now, so the inline slot is neither
452
+ // read nor written, and the page's `clip` is still exactly `clip` while a
453
+ // panel covers the page — not merely restored once the panels have gone.
441
454
  document.body.style.overflow = 'clip';
442
455
 
443
- const { container } = renderBlock({ displayMode: 'trigger' });
444
- act(() => trigger(container).click());
445
- expect(document.body.style.overflow).toBe('hidden');
456
+ const a = renderBlock({ displayMode: 'trigger' });
457
+ const b = renderBlock({ displayMode: 'trigger' });
458
+
459
+ act(() => trigger(a.container).click());
460
+ act(() => trigger(b.container).click());
461
+ expect(isScrollLocked()).toBe(true);
462
+ expect(document.body.style.overflow).toBe('clip');
463
+
464
+ // Escape backs out of the panel on top; the one underneath still holds the
465
+ // page, and the page's own value is still nobody's to hand back.
466
+ act(() => {
467
+ fireEvent.keyDown(document, { key: 'Escape' });
468
+ });
469
+ expect(isScrollLocked()).toBe(true);
470
+ expect(document.body.style.overflow).toBe('clip');
446
471
 
447
472
  act(() => {
448
473
  fireEvent.keyDown(document, { key: 'Escape' });
449
474
  });
475
+ expect(isScrollLocked()).toBe(false);
450
476
  expect(document.body.style.overflow).toBe('clip');
451
477
  });
452
478
  });
@@ -466,6 +492,7 @@ describe('store locator — the designer is not a shopper', () => {
466
492
  expect(dialog).toBeTruthy();
467
493
  expect(dialog.getAttribute('aria-modal')).toBeNull();
468
494
  expect(document.activeElement).toBe(outside);
495
+ expect(isScrollLocked()).toBe(false);
469
496
  expect(document.body.style.overflow).toBe('');
470
497
 
471
498
  // Nothing a click reaches may close the panel being styled.
@@ -144,7 +144,13 @@ describe('TextBlock leaves the block box geometry to the wrapper', () => {
144
144
  expect(div.getAttribute('style')).toBeNull();
145
145
  });
146
146
 
147
- it('still wears everything else, which per-row bindings depend on', () => {
147
+ /**
148
+ * The sheet now writes these against this element itself (`data-part="root"`,
149
+ * see INNER_ROOT_BLOCK_TYPES), not against the wrapper it sits in. An inline
150
+ * copy would apply them a second time and — being inline — outrank the media
151
+ * query and the `:hover` the same sheet emits for the element.
152
+ */
153
+ it('leaves a plain value to the sheet that now targets this element', () => {
148
154
  const { container } = renderBlock('hello', {
149
155
  'background-color': { desktop: '#00a86b' },
150
156
  color: { desktop: '#610529' },
@@ -154,9 +160,37 @@ describe('TextBlock leaves the block box geometry to the wrapper', () => {
154
160
 
155
161
  const div = container.querySelector('div') as HTMLDivElement;
156
162
 
163
+ expect(div.getAttribute('style')).toBeNull();
164
+ });
165
+
166
+ /**
167
+ * The one value the sheet genuinely cannot write, which is what the inline
168
+ * copy is still for: one rule serves every clone of an iterator, so a colour
169
+ * a row resolved for itself has nowhere else to live.
170
+ */
171
+ it('still inlines what an iterator row resolved for itself', () => {
172
+ const block: any = {
173
+ id: 'b1',
174
+ type: 'text',
175
+ value: 'hello',
176
+ styles: {
177
+ 'background-color': { desktop: '#00a86b' },
178
+ 'margin-bottom': { desktop: '29px' }
179
+ },
180
+ rowStyleKeys: ['background-color']
181
+ };
182
+ const { container } = render(
183
+ <TextBlock
184
+ block={block}
185
+ placeholderId="p1"
186
+ sectionId="s1"
187
+ isDesigner={false}
188
+ />
189
+ );
190
+
191
+ const div = container.querySelector('div') as HTMLDivElement;
192
+
157
193
  expect(div.style.backgroundColor).toBe('rgb(0, 168, 107)');
158
- expect(div.style.color).toBe('rgb(97, 5, 41)');
159
- expect(div.style.borderRadius).toBe('4px');
160
194
  expect(div.style.marginBottom).toBe('');
161
195
  });
162
196
 
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { render, fireEvent, act } from '@testing-library/react';
3
3
  import VariantSelectorBlock from '../variant-selector-block';
4
4
  import { ThemePageContextProvider } from '../../theme-page-context';
5
+ import { isScrollLocked } from '../../hooks/use-overlay';
5
6
  import GOLDEN from './variant-selector-golden.json';
6
7
 
7
8
  jest.mock('next/navigation', () => ({
@@ -636,6 +637,7 @@ describe('designer safety', () => {
636
637
 
637
638
  fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
638
639
 
640
+ expect(isScrollLocked()).toBe(false);
639
641
  expect(document.body.style.overflow).toBe('');
640
642
  expect(
641
643
  container.querySelector('[data-variant-alert]')?.hasAttribute('aria-modal')
@@ -673,16 +675,22 @@ describe('modal contract', () => {
673
675
  });
674
676
 
675
677
  it('reference-counts the scroll lock across two open pickers', () => {
678
+ // A value the PAGE chose for itself. The lock is an author rule flipped by
679
+ // an attribute, so this inline declaration is none of its business and is
680
+ // asserted at every step below: it has to read `scroll` while the pickers
681
+ // hold the page just as it does once they have both let go.
676
682
  document.body.style.overflow = 'scroll';
677
683
 
678
684
  const first = renderBlock(MODAL);
679
685
  const second = renderBlock(MODAL);
680
686
 
681
687
  fireEvent.click(trigger(first.container, 'L') as HTMLButtonElement);
682
- expect(document.body.style.overflow).toBe('hidden');
688
+ expect(isScrollLocked()).toBe(true);
689
+ expect(document.body.style.overflow).toBe('scroll');
683
690
 
684
691
  fireEvent.click(trigger(second.container, 'L') as HTMLButtonElement);
685
- expect(document.body.style.overflow).toBe('hidden');
692
+ expect(isScrollLocked()).toBe(true);
693
+ expect(document.body.style.overflow).toBe('scroll');
686
694
 
687
695
  // Closed one at a time: Escape is on the document, so it would dismiss
688
696
  // both at once and never exercise the counting.
@@ -692,14 +700,17 @@ describe('modal contract', () => {
692
700
  ) as HTMLButtonElement
693
701
  );
694
702
  // The first letting go must not unlock the page under the second.
695
- expect(document.body.style.overflow).toBe('hidden');
703
+ expect(isScrollLocked()).toBe(true);
704
+ expect(document.body.style.overflow).toBe('scroll');
696
705
 
697
706
  fireEvent.click(
698
707
  second.container.querySelector(
699
708
  '[data-variant-alert-close]'
700
709
  ) as HTMLButtonElement
701
710
  );
702
- // Only the last holder restores what the page had before either opened.
711
+ // Only the last holder out lets the page scroll again, and it does so by
712
+ // dropping the attribute rather than by handing anything back.
713
+ expect(isScrollLocked()).toBe(false);
703
714
  expect(document.body.style.overflow).toBe('scroll');
704
715
  });
705
716
 
@@ -735,6 +746,7 @@ describe('modal contract', () => {
735
746
  const panel = container.querySelector('[data-variant-alert]');
736
747
  expect(panel?.hasAttribute('aria-modal')).toBe(false);
737
748
  expect(panel?.getAttribute('role')).toBe('group');
749
+ expect(isScrollLocked()).toBe(false);
738
750
  expect(document.body.style.overflow).toBe('');
739
751
  });
740
752
 
@@ -0,0 +1,214 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { useAuthFieldError } from '../auth/auth-field-errors';
6
+ import { getResponsiveValue, partAttrs } from '../utils';
7
+ import { DesignerPlaceholder, readToggle } from './product-block-utils';
8
+ import { BlockRendererProps } from './block-renderer-registry';
9
+
10
+ /**
11
+ * One consent tick of a composed registration.
12
+ *
13
+ * A register form is fields plus consents, and the fields already compose:
14
+ * an `auth-field` per input, arranged and dressed like any other block. The
15
+ * consents did not, so a composition could carry its own e-mail row and its own
16
+ * password row but had to accept whatever row the form drew for the legal
17
+ * opt-in — which is the one row a brand's legal team actually rewrites.
18
+ *
19
+ * A tick is not an `auth-field`: it is a checkbox beside a sentence rather than
20
+ * a caption above an input, its sentence is long enough to wrap and to carry
21
+ * links, and what it submits is presence rather than text. So it is its own
22
+ * block, with the part names the fixed form already used (`consent`,
23
+ * `checkbox`, `consent-text`, `error`) so a brand's existing styling reads the
24
+ * same way.
25
+ *
26
+ * The value travels the way every composed field's does — a `name` the form
27
+ * reads out of `FormData` — and an unticked checkbox simply is not in there,
28
+ * which is exactly the "did they agree" question the form asks.
29
+ *
30
+ * Properties: consentType, text, required, and for a `custom` tick the
31
+ * name it submits and the value it sends when ticked.
32
+ * Parts: consent, checkbox, consent-text, error.
33
+ */
34
+
35
+ export type AuthConsentType = 'legal' | 'marketing' | 'custom';
36
+
37
+ /**
38
+ * What each tick is called on the wire.
39
+ *
40
+ * `is_allowed` is the membership agreement, which commerce requires to register
41
+ * at all; `permissions` is the marketing opt-in, and the form turns it into the
42
+ * four channel flags because commerce wants them named individually. The names
43
+ * are the fixed form's, so a composition and a fixed form post the same body.
44
+ */
45
+ export const AUTH_CONSENT_NAMES = {
46
+ legal: 'is_allowed',
47
+ marketing: 'permissions'
48
+ } as const;
49
+
50
+ /**
51
+ * Whether the row is standing inside a form that READS the two known ticks.
52
+ *
53
+ * `is_allowed` and `permissions` are not values a form forwards, they are
54
+ * questions the register form ANSWERS: it turns the first into the flags
55
+ * commerce will not create an account without, and the second into the four
56
+ * channel flags. Any other composed form just copies what it finds, so a legal
57
+ * tick dropped into a login form posted the browser's own `on` — a string
58
+ * commerce does not read as a consent — while looking to the author like a
59
+ * working row.
60
+ *
61
+ * The editor's palette already offers the row only under a Register Form, but
62
+ * that guard is one nesting level deep: a Group inside a login form takes
63
+ * anything. So the block says for itself where it works, the same way it
64
+ * already refuses to draw a custom tick nobody has named.
65
+ */
66
+ export const AuthConsentHostContext = React.createContext(false);
67
+
68
+ /** Put around the children of a form that answers the two known ticks. */
69
+ export const AuthConsentHostProvider = AuthConsentHostContext.Provider;
70
+
71
+ const DEFAULT_TEXT: Record<AuthConsentType, string> = {
72
+ legal:
73
+ 'I have read and accept the membership agreement and the privacy notice.',
74
+ marketing: 'I would like to receive campaign and marketing messages.',
75
+ // A custom tick is whatever the brand has to ask this month; nothing here
76
+ // could guess its sentence.
77
+ custom: ''
78
+ };
79
+
80
+ const AuthConsentBlock = ({
81
+ block,
82
+ currentBreakpoint = 'desktop',
83
+ isDesigner = false
84
+ }: BlockRendererProps) => {
85
+ const rawType = String(
86
+ getResponsiveValue(
87
+ block.properties?.consentType,
88
+ currentBreakpoint,
89
+ 'legal'
90
+ )
91
+ ).trim();
92
+ const consentType: AuthConsentType =
93
+ rawType === 'marketing' || rawType === 'custom' ? rawType : 'legal';
94
+
95
+ /**
96
+ * The two known ticks own their names because commerce owns their meaning: the
97
+ * register form turns `is_allowed` into the flags an account cannot be created
98
+ * without, and `permissions` into the four channel flags. A `custom` tick is
99
+ * asked for by a brand and read by nothing here, so its name is the author's —
100
+ * which is what makes a third agreement an editor job rather than a release.
101
+ *
102
+ * It needs no special case on the form's side: a composed submit already
103
+ * forwards every named control it finds, so a custom tick travels exactly as
104
+ * authored, with the value below when it is ticked and absent when it is not.
105
+ */
106
+ const customName = String(
107
+ getResponsiveValue(block.properties?.name, currentBreakpoint, '')
108
+ ).trim();
109
+ const name =
110
+ consentType === 'custom'
111
+ ? customName
112
+ : AUTH_CONSENT_NAMES[consentType as 'legal' | 'marketing'];
113
+
114
+ /**
115
+ * What a ticked custom consent sends. `true` by default because that is what
116
+ * commerce stores for an agreement; a backend that wants `on` or `1` gets it
117
+ * by typing that instead. (The browser's own default for a valueless checkbox
118
+ * is `on`, which is why the value is set explicitly rather than left off.)
119
+ */
120
+ const tickedValue =
121
+ String(
122
+ getResponsiveValue(block.properties?.tickedValue, currentBreakpoint, '')
123
+ ).trim() || 'true';
124
+
125
+ const text = String(
126
+ getResponsiveValue(
127
+ block.properties?.text,
128
+ currentBreakpoint,
129
+ DEFAULT_TEXT[consentType]
130
+ )
131
+ );
132
+
133
+ /**
134
+ * The legal tick defaults to required and the marketing one to optional,
135
+ * because that is what each one IS — but it stays a toggle: a brand whose
136
+ * agreement is not mandatory in its market must be able to say so without
137
+ * swapping the block for another.
138
+ */
139
+ const required = readToggle(
140
+ block.properties?.required,
141
+ currentBreakpoint,
142
+ consentType === 'legal'
143
+ );
144
+
145
+ // The form puts its own sentence here — "you have to accept the agreement"
146
+ // beats "this field is required" for a legal opt-in — and clears it as soon
147
+ // as the box is ticked.
148
+ const errorMessage = useAuthFieldError(name);
149
+ const insideConsentHost = React.useContext(AuthConsentHostContext);
150
+
151
+ // Same refusal as an unnamed custom field: a tick that submits nothing is not
152
+ // a consent, and the canvas has to say what it is waiting for.
153
+ if (!name) {
154
+ if (!isDesigner) return null;
155
+
156
+ return <DesignerPlaceholder label="Consent row (give this tick a Name)" />;
157
+ }
158
+
159
+ // A membership or marketing tick outside the form that reads it would post a
160
+ // string commerce ignores. A CUSTOM tick is the brand's own key, forwarded
161
+ // verbatim by any composed form, so it belongs wherever the author puts it.
162
+ if (consentType !== 'custom' && !insideConsentHost) {
163
+ if (!isDesigner) return null;
164
+
165
+ return (
166
+ <DesignerPlaceholder label="Consent row (the membership and marketing ticks belong in a Register Form — a Custom tick works anywhere)" />
167
+ );
168
+ }
169
+
170
+ return (
171
+ <>
172
+ <label
173
+ data-auth-consent={consentType}
174
+ // Fills its block wrapper; see the note in auth-step-block.tsx.
175
+ className="w-full"
176
+ {...partAttrs('consent')}
177
+ style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}
178
+ >
179
+ <input
180
+ type="checkbox"
181
+ name={name}
182
+ // Only the custom tick carries a value: the two known ones are read
183
+ // by presence and re-expressed as flags, so a value on them would be
184
+ // a string the form throws away.
185
+ value={consentType === 'custom' ? tickedValue : undefined}
186
+ required={required || undefined}
187
+ aria-invalid={errorMessage ? true : undefined}
188
+ {...partAttrs('checkbox')}
189
+ style={{ marginTop: '3px' }}
190
+ />
191
+ <span {...partAttrs('consent-text')}>{text}</span>
192
+ </label>
193
+
194
+ {/* Outside the label rather than inside it: the row is a checkbox beside
195
+ its sentence, and a message wedged in there would sit between them. */}
196
+ {errorMessage && (
197
+ <span
198
+ data-auth-consent-error={name}
199
+ {...partAttrs('error')}
200
+ role="alert"
201
+ style={{
202
+ color:
203
+ 'var(--register-form-error-color, var(--auth-form-error-color, #dc2626))',
204
+ fontSize: '0.875em'
205
+ }}
206
+ >
207
+ {errorMessage}
208
+ </span>
209
+ )}
210
+ </>
211
+ );
212
+ };
213
+
214
+ export default AuthConsentBlock;
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { useAuthFormError } from '../auth/form-error-context';
6
+ import { getResponsiveValue, partAttrs } from '../utils';
7
+ import { BlockRendererProps } from './block-renderer-registry';
8
+ import { DesignerPlaceholder } from './product-block-utils';
9
+
10
+ /**
11
+ * The message the auth form around this block is answering with.
12
+ *
13
+ * A form-level error belongs to the submission rather than to a field, so there
14
+ * is no input for it to sit under and the form itself has nowhere good to put
15
+ * it: appended after its children it lands below the composed CTA and outside
16
+ * whatever wrapper carries the design's padding. Placed as a block it is
17
+ * ordinary content — above the CTA, under the fields, inside the same padded
18
+ * group as everything else — and it inherits that group's spacing like any
19
+ * sibling would.
20
+ *
21
+ * Empty until the form has something to say, and gone again the moment it
22
+ * succeeds: a shopper never sees a reserved gap. In the canvas it draws
23
+ * `previewMessage` instead, so an author can see and dress the state without
24
+ * having to fail a login to reach it.
25
+ *
26
+ * Properties: previewMessage.
27
+ * Parts: root.
28
+ */
29
+ const AuthErrorBlock = ({
30
+ block,
31
+ isDesigner = false,
32
+ currentBreakpoint = 'desktop'
33
+ }: BlockRendererProps) => {
34
+ const message = useAuthFormError();
35
+
36
+ const previewMessage = String(
37
+ getResponsiveValue(
38
+ block.properties?.previewMessage,
39
+ currentBreakpoint,
40
+ 'Something went wrong. Please try again.'
41
+ )
42
+ ).trim();
43
+
44
+ // The canvas shows the state being styled; a storefront shows nothing until
45
+ // there is a real message. An author who empties the preview text gets the
46
+ // placeholder rather than an invisible block they cannot select.
47
+ const shown = message || (isDesigner ? previewMessage : '');
48
+
49
+ if (!shown) {
50
+ if (!isDesigner) return null;
51
+
52
+ return (
53
+ <DesignerPlaceholder label="Form error (shown when the form answers with one)" />
54
+ );
55
+ }
56
+
57
+ return (
58
+ <div
59
+ data-auth-error=""
60
+ // Fills its block wrapper; see the note in auth-step-block.tsx.
61
+ className="w-full"
62
+ // Only the live message is announced. `role="alert"` on the canvas
63
+ // preview would have a designer's screen reader read a failure that has
64
+ // not happened.
65
+ role={message ? 'alert' : undefined}
66
+ data-auth-error-state={message ? 'error' : 'preview'}
67
+ {...partAttrs('root')}
68
+ // Either form's variable, because the block sits in both: a login form
69
+ // and an SMS verification are the same kind of message to a shopper and a
70
+ // brand that has set one colour should not have to set it twice.
71
+ style={{
72
+ margin: 0,
73
+ color:
74
+ 'var(--auth-form-error-color, var(--otp-form-error-color, #dc2626))'
75
+ }}
76
+ >
77
+ {shown}
78
+ </div>
79
+ );
80
+ };
81
+
82
+ export default AuthErrorBlock;