@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.
- package/CHANGELOG.md +39 -33
- package/package.json +4 -4
- package/src/__tests__/auth-flow-block.test.tsx +211 -0
- package/src/__tests__/auth-flow-bus.test.ts +290 -0
- package/src/__tests__/auth-form-error-block.test.tsx +326 -0
- package/src/__tests__/auth-form-validation.test.tsx +204 -0
- package/src/__tests__/auth-step-title.test.tsx +202 -0
- package/src/__tests__/detached-submit-state.test.tsx +216 -0
- package/src/__tests__/forgot-password-composed.test.tsx +313 -0
- package/src/__tests__/otp-form-composed.test.tsx +487 -0
- package/src/__tests__/password-reset-step.test.tsx +269 -0
- package/src/__tests__/register-form-composed.test.tsx +739 -0
- package/src/__tests__/registry-parity.test.ts +9 -0
- package/src/auth/auth-field-errors.tsx +151 -0
- package/src/auth/auth-flow-bus.ts +415 -0
- package/src/auth/form-error-context.tsx +55 -0
- package/src/auth/form-submit-bus.ts +93 -0
- package/src/auth/otp-form-context.tsx +80 -0
- package/src/blocks/__tests__/button-block.test.tsx +55 -0
- package/src/blocks/__tests__/link-block.test.tsx +48 -30
- package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
- package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
- package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
- package/src/blocks/__tests__/text-block.test.tsx +37 -3
- package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
- package/src/blocks/auth-consent-block.tsx +214 -0
- package/src/blocks/auth-error-block.tsx +82 -0
- package/src/blocks/auth-field-block.tsx +270 -0
- package/src/blocks/auth-flow-block.tsx +251 -0
- package/src/blocks/auth-form-block.tsx +391 -47
- package/src/blocks/auth-step-block.tsx +81 -0
- package/src/blocks/auth-step-title-block.tsx +81 -0
- package/src/blocks/block-renderer-registry.tsx +18 -0
- package/src/blocks/button-block.tsx +91 -13
- package/src/blocks/forgot-password-block.tsx +318 -24
- package/src/blocks/link-block.tsx +22 -84
- package/src/blocks/otp-code-input-block.tsx +180 -0
- package/src/blocks/otp-form-block.tsx +1092 -0
- package/src/blocks/otp-phase-block.tsx +71 -0
- package/src/blocks/register-form-block.tsx +541 -74
- package/src/blocks/text-block.tsx +17 -40
- package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
- package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
- package/src/hooks/use-block-action.ts +87 -4
- package/src/hooks/use-overlay.ts +60 -9
- package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
- package/src/overlays/__tests__/modal-registry.test.ts +6 -1
- package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
- package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
- package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
- package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
- package/src/overlays/modal-preview-host.tsx +39 -2
- package/src/overlays/modal-registry.ts +344 -4
- package/src/overlays/overlay-surface.tsx +249 -19
- package/src/overlays/overlay-tokens.ts +94 -0
- package/src/overlays/theme-modal-mount.tsx +34 -4
- package/src/theme-block.tsx +6 -0
- package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
- package/src/utils/index.ts +107 -6
- package/src/utils/iterator-utils.ts +39 -2
- 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(
|
|
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(
|
|
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(
|
|
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(
|
|
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('
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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;
|