@akinon/pz-theme 2.0.95-rc.0 → 2.0.95
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 +8 -33
- package/package.json +4 -4
- package/src/__tests__/auth-captcha.test.tsx +261 -0
- package/src/__tests__/auth-commerce-events.test.tsx +5 -1
- package/src/__tests__/auth-destination.test.tsx +348 -0
- package/src/__tests__/auth-error-reading.test.ts +232 -0
- package/src/__tests__/auth-flow-scope.test.tsx +162 -0
- package/src/__tests__/auth-form-error-block.test.tsx +2 -0
- package/src/__tests__/auth-form-success-block.test.tsx +429 -0
- package/src/__tests__/auth-form-validation.test.tsx +2 -0
- package/src/__tests__/auth-slot-coverage.test.ts +9 -1
- package/src/__tests__/auth-step-title.test.tsx +2 -0
- package/src/__tests__/block-action-audience.test.ts +154 -0
- package/src/__tests__/detached-submit-state.test.tsx +80 -2
- package/src/__tests__/forgot-password-composed.test.tsx +2 -0
- package/src/__tests__/form-state-composition.test.tsx +484 -0
- package/src/__tests__/modal-login-close.test.tsx +366 -0
- package/src/__tests__/otp-form-composed.test.tsx +2 -0
- package/src/__tests__/password-reset-step.test.tsx +2 -0
- package/src/__tests__/register-form-composed.test.tsx +253 -4
- package/src/__tests__/registry-parity.test.ts +12 -0
- package/src/__tests__/reset-password-composed.test.tsx +385 -0
- package/src/auth/auth-captcha.tsx +125 -0
- package/src/auth/auth-destination.ts +168 -0
- package/src/auth/auth-field-errors.tsx +31 -0
- package/src/auth/form-state-context.tsx +170 -0
- package/src/auth/form-success-context.tsx +56 -0
- package/src/auth/read-auth-error.ts +332 -0
- package/src/blocks/__tests__/slider-block.test.tsx +265 -1
- package/src/blocks/account-button-block.tsx +25 -0
- package/src/blocks/add-to-cart-block.tsx +15 -0
- package/src/blocks/auth-field-block.tsx +13 -3
- package/src/blocks/auth-flow-block.tsx +45 -3
- package/src/blocks/auth-form-block.tsx +337 -54
- package/src/blocks/auth-success-block.tsx +85 -0
- package/src/blocks/block-renderer-registry.tsx +8 -0
- package/src/blocks/button-block.tsx +43 -13
- package/src/blocks/captcha-block.tsx +61 -0
- package/src/blocks/change-password-form-block.tsx +15 -0
- package/src/blocks/forgot-password-block.tsx +132 -9
- package/src/blocks/form-state-block.tsx +84 -0
- package/src/blocks/newsletter-form-block.tsx +16 -0
- package/src/blocks/otp-form-block.tsx +161 -67
- package/src/blocks/profile-form-block.tsx +13 -0
- package/src/blocks/register-form-block.tsx +362 -110
- package/src/blocks/reset-password-block.tsx +717 -0
- package/src/blocks/slider-block.tsx +170 -4
- package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
- package/src/chrome/placeholder-slugs.ts +3 -0
- package/src/chrome/route-slug.ts +11 -0
- package/src/index.ts +10 -0
- package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
- package/src/overlays/__tests__/modal-registry.test.ts +5 -1
- package/src/overlays/modal-chrome-css.ts +8 -1
- package/src/overlays/modal-host-context.tsx +66 -0
- package/src/overlays/modal-preview-host.tsx +7 -2
- package/src/overlays/modal-registry.ts +38 -1
- package/src/overlays/overlay-loading.tsx +144 -0
- package/src/overlays/overlay-surface.tsx +23 -0
- package/src/overlays/overlay-tokens.ts +12 -0
- package/src/overlays/theme-modal-mount.tsx +85 -6
- package/src/utils/visibility-rules.ts +114 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { useAuthFormSuccess } from '../auth/form-success-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 confirmation the form around this block finished with.
|
|
12
|
+
*
|
|
13
|
+
* The success twin of the Form Error block, and the reason it exists is the
|
|
14
|
+
* same complaint: "the success message is not something I can manage". It was
|
|
15
|
+
* a node the form drew itself, reachable only as a style part — a brand could
|
|
16
|
+
* recolour "a reset link is on its way" and could not put an icon beside it, a
|
|
17
|
+
* heading above it, or the sentence anywhere other than where the form chose.
|
|
18
|
+
*
|
|
19
|
+
* As a block it is ordinary content, so everything a design wants AROUND the
|
|
20
|
+
* confirmation is an ordinary sibling. Placing it also keeps the composition on
|
|
21
|
+
* screen in the forms whose success state would otherwise replace it — which is
|
|
22
|
+
* the point: a block inside a form that has been swapped out draws nothing.
|
|
23
|
+
* When the whole screen should change instead, the form's Step After Success is
|
|
24
|
+
* still the tool for that, and it needs no block at all.
|
|
25
|
+
*
|
|
26
|
+
* Empty until the form has finished, so a shopper never sees a reserved gap. In
|
|
27
|
+
* the canvas it draws `previewMessage` instead, so the state can be dressed
|
|
28
|
+
* without having to reach it for real.
|
|
29
|
+
*
|
|
30
|
+
* Properties: previewMessage.
|
|
31
|
+
* Parts: root.
|
|
32
|
+
*/
|
|
33
|
+
const AuthSuccessBlock = ({
|
|
34
|
+
block,
|
|
35
|
+
isDesigner = false,
|
|
36
|
+
currentBreakpoint = 'desktop'
|
|
37
|
+
}: BlockRendererProps) => {
|
|
38
|
+
const message = useAuthFormSuccess();
|
|
39
|
+
|
|
40
|
+
const previewMessage = String(
|
|
41
|
+
getResponsiveValue(
|
|
42
|
+
block.properties?.previewMessage,
|
|
43
|
+
currentBreakpoint,
|
|
44
|
+
'Done. Check your inbox.'
|
|
45
|
+
)
|
|
46
|
+
).trim();
|
|
47
|
+
|
|
48
|
+
// The canvas shows the state being styled; a storefront shows nothing until
|
|
49
|
+
// the form has actually finished. An author who empties the preview text gets
|
|
50
|
+
// the placeholder rather than an invisible block they cannot select.
|
|
51
|
+
const shown = message || (isDesigner ? previewMessage : '');
|
|
52
|
+
|
|
53
|
+
if (!shown) {
|
|
54
|
+
if (!isDesigner) return null;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<DesignerPlaceholder label="Form success (shown once the form finishes)" />
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
data-auth-success=""
|
|
64
|
+
// Fills its block wrapper; see the note in auth-step-block.tsx.
|
|
65
|
+
className="w-full"
|
|
66
|
+
// Only the live message is announced. `role="status"` on the canvas
|
|
67
|
+
// preview would have a designer's screen reader read a confirmation for
|
|
68
|
+
// something that has not happened.
|
|
69
|
+
role={message ? 'status' : undefined}
|
|
70
|
+
data-auth-success-state={message ? 'success' : 'preview'}
|
|
71
|
+
{...partAttrs('root')}
|
|
72
|
+
// No inline colour, unlike its error twin: that block sets one because
|
|
73
|
+
// the auth forms paint their message through a variable a brand may
|
|
74
|
+
// already have set, while a confirmation has never had a colour of its
|
|
75
|
+
// own. So this line inherits the composition's text colour, and the
|
|
76
|
+
// block's own Text Color — emitted `!important` as every part style is —
|
|
77
|
+
// is what overrides it.
|
|
78
|
+
style={{ margin: 0 }}
|
|
79
|
+
>
|
|
80
|
+
{shown}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export default AuthSuccessBlock;
|
|
@@ -79,10 +79,14 @@ import AuthFlowBlock from './auth-flow-block';
|
|
|
79
79
|
import AuthStepBlock from './auth-step-block';
|
|
80
80
|
import AuthStepTitleBlock from './auth-step-title-block';
|
|
81
81
|
import AuthErrorBlock from './auth-error-block';
|
|
82
|
+
import AuthSuccessBlock from './auth-success-block';
|
|
83
|
+
import FormStateBlock from './form-state-block';
|
|
82
84
|
import AuthConsentBlock from './auth-consent-block';
|
|
83
85
|
import AuthFieldBlock from './auth-field-block';
|
|
84
86
|
import OtpPhaseBlock from './otp-phase-block';
|
|
85
87
|
import OtpCodeInputBlock from './otp-code-input-block';
|
|
88
|
+
import CaptchaBlock from './captcha-block';
|
|
89
|
+
import ResetPasswordBlock from './reset-password-block';
|
|
86
90
|
import StoreLocatorBlock from './store-locator-block';
|
|
87
91
|
import FaqAccordionBlock from './faq-accordion-block';
|
|
88
92
|
import ShareBlock from './share-block';
|
|
@@ -224,10 +228,14 @@ blockRendererRegistry.register('auth-flow', AuthFlowBlock);
|
|
|
224
228
|
blockRendererRegistry.register('auth-step', AuthStepBlock);
|
|
225
229
|
blockRendererRegistry.register('auth-step-title', AuthStepTitleBlock);
|
|
226
230
|
blockRendererRegistry.register('auth-error', AuthErrorBlock);
|
|
231
|
+
blockRendererRegistry.register('auth-success', AuthSuccessBlock);
|
|
232
|
+
blockRendererRegistry.register('form-state', FormStateBlock);
|
|
227
233
|
blockRendererRegistry.register('auth-consent', AuthConsentBlock);
|
|
228
234
|
blockRendererRegistry.register('auth-field', AuthFieldBlock);
|
|
229
235
|
blockRendererRegistry.register('otp-phase', OtpPhaseBlock);
|
|
230
236
|
blockRendererRegistry.register('otp-code-input', OtpCodeInputBlock);
|
|
237
|
+
blockRendererRegistry.register('captcha', CaptchaBlock);
|
|
238
|
+
blockRendererRegistry.register('reset-password', ResetPasswordBlock);
|
|
231
239
|
blockRendererRegistry.register('store-locator', StoreLocatorBlock);
|
|
232
240
|
blockRendererRegistry.register('faq-accordion', FaqAccordionBlock);
|
|
233
241
|
blockRendererRegistry.register('share', ShareBlock);
|
|
@@ -87,8 +87,29 @@ const ButtonBlock = ({
|
|
|
87
87
|
return 'Click Me';
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
const submitFormId = String(
|
|
91
|
+
getResponsiveValue(block.properties?.submitFormId, currentBreakpoint, '')
|
|
92
|
+
).trim();
|
|
93
|
+
/**
|
|
94
|
+
* A detached CTA is a real submit button and has to behave like one while the
|
|
95
|
+
* form it drives is working: staying bright and clickable through a request
|
|
96
|
+
* is what makes a shopper press it twice. An empty id costs no subscription,
|
|
97
|
+
* so this is safe to read unconditionally — as a hook must be.
|
|
98
|
+
*/
|
|
99
|
+
const isSubmittedFormBusy = useFormSubmitState(submitFormId);
|
|
100
|
+
|
|
90
101
|
const textValue = block.properties?.text || block.value;
|
|
91
|
-
|
|
102
|
+
/**
|
|
103
|
+
* What the caption says while the form this button drives is working.
|
|
104
|
+
*
|
|
105
|
+
* Empty keeps the caption put, which is what every published button does —
|
|
106
|
+
* the same absence-preserving default the forms' own `busyLabel` carries.
|
|
107
|
+
*/
|
|
108
|
+
const busyTextValue = block.properties?.busyText;
|
|
109
|
+
const text =
|
|
110
|
+
isSubmittedFormBusy && busyTextValue
|
|
111
|
+
? getLocalizedText(busyTextValue)
|
|
112
|
+
: getLocalizedText(textValue);
|
|
92
113
|
|
|
93
114
|
const url = block.properties?.url
|
|
94
115
|
? String(getResponsiveValue(block.properties.url, currentBreakpoint, '#'))
|
|
@@ -133,16 +154,6 @@ const ButtonBlock = ({
|
|
|
133
154
|
* live in a pinned bottom bar while the fields stay in the flow above.
|
|
134
155
|
* Forces the <button> branch: an anchor cannot submit anything.
|
|
135
156
|
*/
|
|
136
|
-
const submitFormId = String(
|
|
137
|
-
getResponsiveValue(block.properties?.submitFormId, currentBreakpoint, '')
|
|
138
|
-
).trim();
|
|
139
|
-
/**
|
|
140
|
-
* A detached CTA is a real submit button and has to behave like one while the
|
|
141
|
-
* form it drives is working: staying bright and clickable through a request
|
|
142
|
-
* is what makes a shopper press it twice. An empty id costs no subscription,
|
|
143
|
-
* so this is safe to read unconditionally — as a hook must be.
|
|
144
|
-
*/
|
|
145
|
-
const isSubmittedFormBusy = useFormSubmitState(submitFormId);
|
|
146
157
|
|
|
147
158
|
const iconUrl = block.properties?.icon
|
|
148
159
|
? String(getResponsiveValue(block.properties.icon, currentBreakpoint, ''))
|
|
@@ -660,8 +671,27 @@ const ButtonBlock = ({
|
|
|
660
671
|
// root styles HERE instead of onto the designer wrapper, so padding
|
|
661
672
|
// and border land once, on the element the shopper actually clicks —
|
|
662
673
|
// not once on the wrapper and again inline (which doubled them).
|
|
663
|
-
|
|
664
|
-
|
|
674
|
+
//
|
|
675
|
+
// The states travel with it: `disabled` alone changed nothing a
|
|
676
|
+
// shopper could see, because an authored background and colour
|
|
677
|
+
// override what a browser paints on a disabled button — so a
|
|
678
|
+
// detached CTA stayed bright and clickable-looking through the whole
|
|
679
|
+
// request, which is exactly what makes someone press it twice.
|
|
680
|
+
{...partAttrs('root', {
|
|
681
|
+
loading: isSubmittedFormBusy,
|
|
682
|
+
disabled: isSubmittedFormBusy
|
|
683
|
+
})}
|
|
684
|
+
style={
|
|
685
|
+
isSubmittedFormBusy
|
|
686
|
+
? {
|
|
687
|
+
...finalStyles,
|
|
688
|
+
// A default a designer does not have to author, and the same
|
|
689
|
+
// one the forms' own submit buttons use.
|
|
690
|
+
opacity: 0.7,
|
|
691
|
+
cursor: 'not-allowed'
|
|
692
|
+
}
|
|
693
|
+
: finalStyles
|
|
694
|
+
}
|
|
665
695
|
onClick={handleClick}
|
|
666
696
|
{...action.ariaProps}
|
|
667
697
|
>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
5
|
+
import { useAuthCaptcha } from '../auth/auth-captcha';
|
|
6
|
+
import { DesignerPlaceholder } from './product-block-utils';
|
|
7
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where the security check appears inside an auth form.
|
|
11
|
+
*
|
|
12
|
+
* The challenge itself is not a design decision — commerce decides when to
|
|
13
|
+
* escalate, and the widget is Google's — but WHERE it lands is: above the
|
|
14
|
+
* submit, under the password, inside the drawer's scrolling area rather than
|
|
15
|
+
* behind its bottom bar. So it is a block, and the form it sits in offers the
|
|
16
|
+
* widget through `auth/auth-captcha.tsx`.
|
|
17
|
+
*
|
|
18
|
+
* Renders nothing at all until there is something to render: the form has
|
|
19
|
+
* `showCaptcha` off, or commerce has not escalated. That is what lets a
|
|
20
|
+
* designer place it once and forget it, instead of composing a second version
|
|
21
|
+
* of the form for the abuse-protected case.
|
|
22
|
+
*
|
|
23
|
+
* Properties: label. Parts: captcha.
|
|
24
|
+
*/
|
|
25
|
+
const CaptchaBlock = ({
|
|
26
|
+
block,
|
|
27
|
+
currentBreakpoint = 'desktop',
|
|
28
|
+
isDesigner = false
|
|
29
|
+
}: BlockRendererProps) => {
|
|
30
|
+
const captcha = useAuthCaptcha();
|
|
31
|
+
|
|
32
|
+
const label = String(
|
|
33
|
+
getResponsiveValue(
|
|
34
|
+
block.properties?.label,
|
|
35
|
+
currentBreakpoint,
|
|
36
|
+
'Security check'
|
|
37
|
+
)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* In the canvas it is a marker rather than a live widget. Two reasons, and
|
|
42
|
+
* the first is enough on its own: rendering reCAPTCHA into an editor iframe
|
|
43
|
+
* asks Google for a token against the wrong origin. The second is that a
|
|
44
|
+
* designer has to be able to SEE and select a block whose whole point is to
|
|
45
|
+
* be invisible most of the time.
|
|
46
|
+
*/
|
|
47
|
+
if (isDesigner) {
|
|
48
|
+
return <DesignerPlaceholder label={label} attribute="data-auth-captcha" />;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// No form is offering a challenge — see the note above.
|
|
52
|
+
if (!captcha) return null;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div {...partAttrs('captcha')} data-auth-captcha="">
|
|
56
|
+
<captcha.View />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export default CaptchaBlock;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { useState } from 'react';
|
|
4
4
|
import { useUpdatePasswordMutation } from '@akinon/next/data/client/account';
|
|
5
5
|
import { getResponsiveValue, partAttrs } from '../utils';
|
|
6
|
+
import { useHostModalCloser } from '../overlays/modal-host-context';
|
|
6
7
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
8
|
import { readToggle } from './product-block-utils';
|
|
8
9
|
|
|
@@ -63,6 +64,19 @@ const ChangePasswordFormBlock = ({
|
|
|
63
64
|
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
64
65
|
);
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Whether finishing closes the modal this block is inside. OFF by default
|
|
69
|
+
* here, because this block's success state is something the shopper is meant
|
|
70
|
+
* to read — see `useHostModalCloser`.
|
|
71
|
+
*/
|
|
72
|
+
const closeHostModalOnSuccess = readToggle(
|
|
73
|
+
block.properties?.closeHostModalOnSuccess,
|
|
74
|
+
currentBreakpoint,
|
|
75
|
+
false
|
|
76
|
+
);
|
|
77
|
+
const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
|
|
78
|
+
|
|
79
|
+
|
|
66
80
|
const submitLabel = text('submitLabel', 'Change password');
|
|
67
81
|
const submitAlign = text('submitAlign', 'stretch');
|
|
68
82
|
const currentPasswordLabel = text('currentPasswordLabel', 'Current password');
|
|
@@ -125,6 +139,7 @@ const ChangePasswordFormBlock = ({
|
|
|
125
139
|
await updatePassword(values).unwrap();
|
|
126
140
|
setValues(EMPTY_FORM);
|
|
127
141
|
setIsDone(true);
|
|
142
|
+
closeHostModal();
|
|
128
143
|
} catch (requestError) {
|
|
129
144
|
setError(readPasswordError(requestError, errorMessage));
|
|
130
145
|
}
|
|
@@ -8,12 +8,28 @@ import {
|
|
|
8
8
|
AuthFieldErrors,
|
|
9
9
|
AuthFieldErrorsProvider,
|
|
10
10
|
clearAuthFieldError,
|
|
11
|
-
collectAuthFieldErrors
|
|
11
|
+
collectAuthFieldErrors,
|
|
12
|
+
focusFirstAuthFieldError
|
|
12
13
|
} from '../auth/auth-field-errors';
|
|
14
|
+
import {
|
|
15
|
+
authFormMessage,
|
|
16
|
+
readCommerceError,
|
|
17
|
+
splitByKnownFields
|
|
18
|
+
} from '../auth/read-auth-error';
|
|
13
19
|
import {
|
|
14
20
|
AuthFormErrorProvider,
|
|
15
21
|
containsAuthErrorBlock
|
|
16
22
|
} from '../auth/form-error-context';
|
|
23
|
+
import {
|
|
24
|
+
AuthFormSuccessProvider,
|
|
25
|
+
containsAuthSuccessBlock
|
|
26
|
+
} from '../auth/form-success-context';
|
|
27
|
+
import {
|
|
28
|
+
AuthFormStateProvider,
|
|
29
|
+
AuthFormStateValue,
|
|
30
|
+
containsFormStateBlock,
|
|
31
|
+
showsInFormState
|
|
32
|
+
} from '../auth/form-state-context';
|
|
17
33
|
import {
|
|
18
34
|
clearFormSubmitState,
|
|
19
35
|
publishFormSubmitState
|
|
@@ -24,8 +40,17 @@ import {
|
|
|
24
40
|
setAuthStep
|
|
25
41
|
} from '../auth/auth-flow-bus';
|
|
26
42
|
import { renderComposedChildren } from './render-composed-children';
|
|
43
|
+
import { useHostModalCloser } from '../overlays/modal-host-context';
|
|
27
44
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
28
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The only name this form submits under, and therefore the only one a
|
|
48
|
+
* field-level refusal can be hung on. Named as well as measured, because a
|
|
49
|
+
* composition whose CTA is a detached Button still has to attribute
|
|
50
|
+
* "no account with this address" to the address field.
|
|
51
|
+
*/
|
|
52
|
+
const FORGOT_PASSWORD_FIELD_NAMES = ['email'] as const;
|
|
53
|
+
|
|
29
54
|
/**
|
|
30
55
|
* Functional auth block: e-mail input that triggers the password reset mail
|
|
31
56
|
* through the v1 `forgotPassword` mutation. Behaviour is fixed (commerce sends
|
|
@@ -58,6 +83,19 @@ const ForgotPasswordBlock = ({
|
|
|
58
83
|
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
59
84
|
);
|
|
60
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Whether finishing closes the modal this block is inside. OFF by default
|
|
88
|
+
* here, because this block's success state is something the shopper is meant
|
|
89
|
+
* to read — see `useHostModalCloser`.
|
|
90
|
+
*/
|
|
91
|
+
const closeHostModalOnSuccess = readToggle(
|
|
92
|
+
block.properties?.closeHostModalOnSuccess,
|
|
93
|
+
currentBreakpoint,
|
|
94
|
+
false
|
|
95
|
+
);
|
|
96
|
+
const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
|
|
97
|
+
|
|
98
|
+
|
|
61
99
|
const submitLabel = prop('submitLabel', 'Send reset link');
|
|
62
100
|
// Empty on purpose: without a busy label the submit caption stays put while
|
|
63
101
|
// the request runs, which is what published blocks render today.
|
|
@@ -143,6 +181,21 @@ const ForgotPasswordBlock = ({
|
|
|
143
181
|
.filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
|
|
144
182
|
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
145
183
|
const isComposed = childBlocks.length > 0;
|
|
184
|
+
/**
|
|
185
|
+
* Whether the composition draws the confirmation itself — either with a Form
|
|
186
|
+
* Success block carrying this form's sentence, or with a whole screen scoped
|
|
187
|
+
* to the success state in a Form State container.
|
|
188
|
+
*
|
|
189
|
+
* Two things follow: the built-in success copy stands down, and the form is
|
|
190
|
+
* NOT replaced by it — a block inside a form that has been swapped out is
|
|
191
|
+
* unmounted and would draw nothing at all, which is the whole reason the
|
|
192
|
+
* confirmation was unmanageable in the first place.
|
|
193
|
+
*/
|
|
194
|
+
const successScreenIsComposed =
|
|
195
|
+
isComposed && containsFormStateBlock(childBlocks, 'success', currentBreakpoint);
|
|
196
|
+
const successIsAuthored =
|
|
197
|
+
successScreenIsComposed ||
|
|
198
|
+
(isComposed && containsAuthSuccessBlock(childBlocks));
|
|
146
199
|
|
|
147
200
|
const [email, setEmail] = useState('');
|
|
148
201
|
const [error, setError] = useState('');
|
|
@@ -185,10 +238,14 @@ const ForgotPasswordBlock = ({
|
|
|
185
238
|
// Submitting inside the designer would send a real reset mail.
|
|
186
239
|
if (isDesigner || isLoading) return;
|
|
187
240
|
|
|
241
|
+
// Held onto because `currentTarget` is only the form for as long as the
|
|
242
|
+
// handler runs, and the refusal is read after an await.
|
|
243
|
+
const formElement = event.currentTarget;
|
|
244
|
+
|
|
188
245
|
// The form carries `noValidate`, so this is what stops an empty submit —
|
|
189
246
|
// and it says so under the field rather than in a browser bubble.
|
|
190
247
|
const { errors: nextFieldErrors, firstInvalid } = collectAuthFieldErrors(
|
|
191
|
-
|
|
248
|
+
formElement,
|
|
192
249
|
{ required: requiredMessage, invalid: invalidMessage }
|
|
193
250
|
);
|
|
194
251
|
|
|
@@ -204,7 +261,7 @@ const ForgotPasswordBlock = ({
|
|
|
204
261
|
// form the way every other composed auth form reads its fields, rather
|
|
205
262
|
// than from state no child writes to.
|
|
206
263
|
const address = isComposed
|
|
207
|
-
? String(new FormData(
|
|
264
|
+
? String(new FormData(formElement).get('email') ?? '').trim()
|
|
208
265
|
: email;
|
|
209
266
|
|
|
210
267
|
try {
|
|
@@ -216,8 +273,28 @@ const ForgotPasswordBlock = ({
|
|
|
216
273
|
if (successStep) {
|
|
217
274
|
setAuthStep(successStep.flowId, successStep.stepKey);
|
|
218
275
|
}
|
|
219
|
-
|
|
220
|
-
|
|
276
|
+
|
|
277
|
+
// Contradictory on purpose-tolerant: a composition that names a success
|
|
278
|
+
// STEP and also asks to close gets the close, because a step inside a
|
|
279
|
+
// closed drawer is moot. Off by default — "check your inbox" is the
|
|
280
|
+
// point of this form's success state.
|
|
281
|
+
closeHostModal();
|
|
282
|
+
} catch (rejection) {
|
|
283
|
+
/**
|
|
284
|
+
* What commerce actually said, rather than "could not send" over a rate
|
|
285
|
+
* limit the shopper cannot see. RTK Query puts the body on `data`; a
|
|
286
|
+
* message naming the address goes under the field, which is where the
|
|
287
|
+
* shopper is looking, and anything form-level stays above it.
|
|
288
|
+
*/
|
|
289
|
+
const reading = splitByKnownFields(
|
|
290
|
+
readCommerceError((rejection as { data?: unknown })?.data),
|
|
291
|
+
formElement,
|
|
292
|
+
FORGOT_PASSWORD_FIELD_NAMES
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
setFieldErrors((current) => ({ ...current, ...reading.fieldMessages }));
|
|
296
|
+
focusFirstAuthFieldError(formElement, reading.fieldMessages);
|
|
297
|
+
setError(authFormMessage(reading, errorLabel));
|
|
221
298
|
}
|
|
222
299
|
};
|
|
223
300
|
|
|
@@ -249,9 +326,26 @@ const ForgotPasswordBlock = ({
|
|
|
249
326
|
</button>
|
|
250
327
|
) : null;
|
|
251
328
|
|
|
329
|
+
/** Finished — for real on a storefront, on the toggle in the canvas. */
|
|
330
|
+
const inSuccessState =
|
|
331
|
+
(isSuccess && !isDesigner) || (isDesigner && previewSuccess);
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* What the composition's Form State containers read. `busy` and `hasError`
|
|
335
|
+
* sit beside the screen rather than replacing it, so a refusal does not take
|
|
336
|
+
* the fields away with it — see `auth/form-state-context.tsx`.
|
|
337
|
+
*/
|
|
338
|
+
const formStateValue: AuthFormStateValue = {
|
|
339
|
+
screen: inSuccessState ? 'success' : 'form',
|
|
340
|
+
busy: isLoading,
|
|
341
|
+
hasError: Boolean(error)
|
|
342
|
+
};
|
|
343
|
+
|
|
252
344
|
// The success copy replaces the form so the shopper is not invited to submit
|
|
253
345
|
// the same address twice; the root attribute is kept so styling still applies.
|
|
254
|
-
|
|
346
|
+
// A composition that placed a Form Success block keeps its own layout
|
|
347
|
+
// instead — see `successIsAuthored`.
|
|
348
|
+
if (inSuccessState && !successIsAuthored) {
|
|
255
349
|
return (
|
|
256
350
|
<div
|
|
257
351
|
data-forgot-password=""
|
|
@@ -279,6 +373,10 @@ const ForgotPasswordBlock = ({
|
|
|
279
373
|
|
|
280
374
|
return (
|
|
281
375
|
<AuthFormErrorProvider value={error}>
|
|
376
|
+
{/* The confirmation, for the block the composition placed for it. Empty
|
|
377
|
+
until the mail is away, so the block draws nothing before then. */}
|
|
378
|
+
<AuthFormSuccessProvider value={inSuccessState ? successLabel : ''}>
|
|
379
|
+
<AuthFormStateProvider value={formStateValue}>
|
|
282
380
|
<AuthFieldErrorsProvider value={fieldErrors}>
|
|
283
381
|
<form
|
|
284
382
|
id={formHtmlId || undefined}
|
|
@@ -286,7 +384,9 @@ const ForgotPasswordBlock = ({
|
|
|
286
384
|
data-forgot-password-composed=""
|
|
287
385
|
// Fills its block wrapper; see the note in auth-step-block.tsx.
|
|
288
386
|
className="w-full"
|
|
289
|
-
data-forgot-password-state={
|
|
387
|
+
data-forgot-password-state={
|
|
388
|
+
inSuccessState ? 'success' : isLoading ? 'busy' : 'idle'
|
|
389
|
+
}
|
|
290
390
|
{...partAttrs('form')}
|
|
291
391
|
onSubmit={submit}
|
|
292
392
|
// Our own message under the field instead of the browser's bubble
|
|
@@ -301,7 +401,17 @@ const ForgotPasswordBlock = ({
|
|
|
301
401
|
}}
|
|
302
402
|
>
|
|
303
403
|
{renderComposedChildren({
|
|
304
|
-
|
|
404
|
+
// Only the containers this screen calls for; everything that is
|
|
405
|
+
// not a container is shared chrome and shows on all of them.
|
|
406
|
+
blocks: childBlocks.filter((childBlock) =>
|
|
407
|
+
showsInFormState({
|
|
408
|
+
block: childBlock,
|
|
409
|
+
state: formStateValue,
|
|
410
|
+
isDesigner,
|
|
411
|
+
selectedBlockId,
|
|
412
|
+
currentBreakpoint
|
|
413
|
+
})
|
|
414
|
+
),
|
|
305
415
|
placeholderId,
|
|
306
416
|
sectionId,
|
|
307
417
|
isDesigner,
|
|
@@ -322,7 +432,14 @@ const ForgotPasswordBlock = ({
|
|
|
322
432
|
</div>
|
|
323
433
|
)}
|
|
324
434
|
|
|
325
|
-
{
|
|
435
|
+
{/* Kept in the success state where the confirmation is only a
|
|
436
|
+
LINE — the address is still valid and the fields are still on
|
|
437
|
+
screen, so pressing it again is a RESEND, the affordance a
|
|
438
|
+
"check your inbox" screen normally wants. Dropped where a Form
|
|
439
|
+
State container owns the success screen: the fields are then
|
|
440
|
+
scoped away with it and this would be an orphan button under a
|
|
441
|
+
composition that never asked for it. */}
|
|
442
|
+
{showSubmit && !(inSuccessState && successScreenIsComposed) && (
|
|
326
443
|
<button
|
|
327
444
|
type="submit"
|
|
328
445
|
data-forgot-password-submit=""
|
|
@@ -344,8 +461,14 @@ const ForgotPasswordBlock = ({
|
|
|
344
461
|
{isLoading && busyLabel ? busyLabel : submitLabel}
|
|
345
462
|
</button>
|
|
346
463
|
)}
|
|
464
|
+
|
|
465
|
+
{/* The authored way back. Not when a container owns the screen:
|
|
466
|
+
the way onward is then part of the composition. */}
|
|
467
|
+
{inSuccessState && !successScreenIsComposed && backControl}
|
|
347
468
|
</form>
|
|
348
469
|
</AuthFieldErrorsProvider>
|
|
470
|
+
</AuthFormStateProvider>
|
|
471
|
+
</AuthFormSuccessProvider>
|
|
349
472
|
</AuthFormErrorProvider>
|
|
350
473
|
);
|
|
351
474
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { readFormStateKey } from '../auth/form-state-context';
|
|
6
|
+
import { partAttrs } from '../utils';
|
|
7
|
+
import { renderComposedChildren } from './render-composed-children';
|
|
8
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
9
|
+
import { DesignerPlaceholder } from './product-block-utils';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One SCREEN of a composed form, as a named wrapper whose contents are
|
|
13
|
+
* ordinary child blocks.
|
|
14
|
+
*
|
|
15
|
+
* This is what makes "the confirmation can be a line of text, or a whole
|
|
16
|
+
* component" true: the confirmation is whatever the designer puts in here — a
|
|
17
|
+
* Text block, or an icon and a heading and a Button, or the Form Success block
|
|
18
|
+
* that carries the form's own sentence. Same for the refusal, the busy moment,
|
|
19
|
+
* and the dead-link screen a password reset can land on. Before this, every
|
|
20
|
+
* state except the form itself was the form's own markup with one authored
|
|
21
|
+
* string in it.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately shaped like `otp-phase` and `auth-step`, and for the same
|
|
24
|
+
* reason: the container decides NOTHING about its own visibility. The form it
|
|
25
|
+
* stands in draws the containers whose state matches — see
|
|
26
|
+
* `auth/form-state-context.tsx` — so dropped anywhere else this is just a
|
|
27
|
+
* labelled column whose children always show, which is what keeps it dressable
|
|
28
|
+
* on its own.
|
|
29
|
+
*
|
|
30
|
+
* Properties: formState.
|
|
31
|
+
* Parts: state.
|
|
32
|
+
*/
|
|
33
|
+
const FormStateBlock = ({
|
|
34
|
+
block,
|
|
35
|
+
placeholderId,
|
|
36
|
+
sectionId,
|
|
37
|
+
isDesigner = false,
|
|
38
|
+
selectedBlockId,
|
|
39
|
+
currentBreakpoint = 'desktop'
|
|
40
|
+
}: BlockRendererProps) => {
|
|
41
|
+
const formState = readFormStateKey(block, currentBreakpoint);
|
|
42
|
+
|
|
43
|
+
const childBlocks = (block.blocks || [])
|
|
44
|
+
.filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
|
|
45
|
+
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
46
|
+
|
|
47
|
+
// An empty container draws nothing a shopper could see; in the canvas it has
|
|
48
|
+
// to say what it is waiting for, or adding one looks like nothing happened.
|
|
49
|
+
if (childBlocks.length === 0 && !isDesigner) return null;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
data-form-state=""
|
|
54
|
+
// Fills its block wrapper; see the note in auth-step-block.tsx.
|
|
55
|
+
className="w-full"
|
|
56
|
+
data-form-state-key={formState || undefined}
|
|
57
|
+
{...partAttrs('state')}
|
|
58
|
+
style={{
|
|
59
|
+
display: 'flex',
|
|
60
|
+
flexDirection: 'column',
|
|
61
|
+
gap: 'var(--form-state-gap, 12px)'
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
{childBlocks.length === 0 ? (
|
|
65
|
+
<DesignerPlaceholder
|
|
66
|
+
label={`Form state${
|
|
67
|
+
formState ? ` · ${formState}` : ''
|
|
68
|
+
} (drop its content here)`}
|
|
69
|
+
/>
|
|
70
|
+
) : (
|
|
71
|
+
renderComposedChildren({
|
|
72
|
+
blocks: childBlocks,
|
|
73
|
+
placeholderId,
|
|
74
|
+
sectionId,
|
|
75
|
+
isDesigner,
|
|
76
|
+
selectedBlockId,
|
|
77
|
+
currentBreakpoint
|
|
78
|
+
})
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export default FormStateBlock;
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import React, { useState } from 'react';
|
|
4
4
|
import { useEmailSubscriptionMutation } from '@akinon/next/data/client/misc';
|
|
5
5
|
import { getResponsiveValue, partAttrs } from '../utils';
|
|
6
|
+
import { useHostModalCloser } from '../overlays/modal-host-context';
|
|
7
|
+
import { readToggle } from './product-block-utils';
|
|
6
8
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
9
|
|
|
8
10
|
/** The wording the block has always shipped with; every label is editable. */
|
|
@@ -37,9 +39,22 @@ const NewsletterFormBlock = ({
|
|
|
37
39
|
currentBreakpoint,
|
|
38
40
|
fallback
|
|
39
41
|
);
|
|
42
|
+
|
|
40
43
|
return value === undefined || value === null ? fallback : String(value);
|
|
41
44
|
};
|
|
42
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Whether finishing closes the modal this block is inside. OFF by default
|
|
48
|
+
* here, because this block's success state is something the shopper is meant
|
|
49
|
+
* to read — see `useHostModalCloser`.
|
|
50
|
+
*/
|
|
51
|
+
const closeHostModalOnSuccess = readToggle(
|
|
52
|
+
block.properties?.closeHostModalOnSuccess,
|
|
53
|
+
currentBreakpoint,
|
|
54
|
+
false
|
|
55
|
+
);
|
|
56
|
+
const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
|
|
57
|
+
|
|
43
58
|
const placeholder = prop('placeholder', 'Your email address');
|
|
44
59
|
const inputLabel = prop('inputLabel', '');
|
|
45
60
|
const submitLabel = prop('submitLabel', 'Subscribe');
|
|
@@ -71,6 +86,7 @@ const NewsletterFormBlock = ({
|
|
|
71
86
|
// form itself is the consent, so it is always sent as true.
|
|
72
87
|
await subscribe({ email: value, subscribe_contract: true }).unwrap();
|
|
73
88
|
setStatus('success');
|
|
89
|
+
closeHostModal();
|
|
74
90
|
setEmail('');
|
|
75
91
|
} catch {
|
|
76
92
|
setErrorKind('request');
|