@akinon/pz-theme 2.0.95-rc.0 → 2.0.95-rc.1
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 +34 -0
- 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/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/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,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a shopper goes after signing in — and where an already signed-in one
|
|
3
|
+
* is sent when they land on a login screen.
|
|
4
|
+
*
|
|
5
|
+
* One resolver for both, because they are the same question asked twice: "what
|
|
6
|
+
* was this shopper trying to reach?" The composable forms used to answer it
|
|
7
|
+
* with a compiled-in `/`, which is the answer least often right — a shopper
|
|
8
|
+
* who was sent to sign in from the basket, or who opened the header's drawer
|
|
9
|
+
* two clicks into a product page, is not asking to be taken to the homepage.
|
|
10
|
+
*
|
|
11
|
+
* Three modes, because the right answer depends on WHERE the form is:
|
|
12
|
+
*
|
|
13
|
+
* - `stay` is what a drawer wants. There is nothing to navigate to: the
|
|
14
|
+
* shopper is already on the page they care about, and the sign-in was a
|
|
15
|
+
* detour. The host closes the modal and refreshes; no push at all.
|
|
16
|
+
* - `return` is what a login PAGE wants: the storefront put the intended
|
|
17
|
+
* destination in the url, and this reads it back.
|
|
18
|
+
* - `fixed` is the historical behaviour and stays the default, so no
|
|
19
|
+
* published composition changes where it lands.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const AUTH_AFTER_SIGN_IN_MODES = ['fixed', 'return', 'stay'] as const;
|
|
23
|
+
|
|
24
|
+
export type AuthAfterSignIn = (typeof AUTH_AFTER_SIGN_IN_MODES)[number];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The query keys a pz storefront carries the destination in.
|
|
28
|
+
*
|
|
29
|
+
* `callbackUrl` is next-auth's own convention and what the brands' own login
|
|
30
|
+
* views read; `next` is what the OAuth middleware writes when it hands a
|
|
31
|
+
* shopper back (`middlewares/oauth-login.ts`). Both are read, first one wins,
|
|
32
|
+
* so a composed form behaves like the hand-written view it replaces.
|
|
33
|
+
*/
|
|
34
|
+
export const AUTH_RETURN_PARAMS = ['callbackUrl', 'next'] as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether a destination is somewhere on THIS storefront.
|
|
38
|
+
*
|
|
39
|
+
* The whole point of reading a destination out of the url is that anyone can
|
|
40
|
+
* write one — a mail, an ad, a link on another site. `?callbackUrl=https://…`
|
|
41
|
+
* would turn every composed login form into an open redirect: the shopper
|
|
42
|
+
* signs in on the brand's own domain and is handed to a page that is not it.
|
|
43
|
+
* Nothing here is ever navigated to unless it is a path on this origin.
|
|
44
|
+
*
|
|
45
|
+
* `//evil.example` is the case worth naming: it starts with a slash, and a
|
|
46
|
+
* browser reads it as protocol-relative — a different host.
|
|
47
|
+
*/
|
|
48
|
+
export const isSafeInternalPath = (value?: string | null): boolean => {
|
|
49
|
+
if (typeof value !== 'string') return false;
|
|
50
|
+
|
|
51
|
+
const path = value.trim();
|
|
52
|
+
|
|
53
|
+
if (!path.startsWith('/')) return false;
|
|
54
|
+
if (path.startsWith('//') || path.startsWith('/\\')) return false;
|
|
55
|
+
// A control character in a url is either an encoding accident or an attempt
|
|
56
|
+
// to smuggle one past a check like this.
|
|
57
|
+
// eslint-disable-next-line no-control-regex
|
|
58
|
+
if (/[\u0000-\u001f\u007f]/.test(path)) return false;
|
|
59
|
+
|
|
60
|
+
return true;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The path itself, or the fallback when it is not ours to navigate to. */
|
|
64
|
+
export const safeInternalPath = (
|
|
65
|
+
value: string | null | undefined,
|
|
66
|
+
fallback: string
|
|
67
|
+
): string => (isSafeInternalPath(value) ? (value as string).trim() : fallback);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The destination the url is asking for, if any.
|
|
71
|
+
*
|
|
72
|
+
* Reads a plain `URLSearchParams` (what `useSearchParams` hands back) so the
|
|
73
|
+
* resolver stays testable without a router.
|
|
74
|
+
*/
|
|
75
|
+
export const readReturnPath = (
|
|
76
|
+
search: { get(name: string): string | null } | null | undefined
|
|
77
|
+
): string | null => {
|
|
78
|
+
if (!search) return null;
|
|
79
|
+
|
|
80
|
+
for (const param of AUTH_RETURN_PARAMS) {
|
|
81
|
+
const value = search.get(param);
|
|
82
|
+
if (isSafeInternalPath(value)) return (value as string).trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return null;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export interface AuthDestinationInput {
|
|
89
|
+
mode: AuthAfterSignIn;
|
|
90
|
+
/** The form's authored destination — the `fixed` answer, and the fallback. */
|
|
91
|
+
authored: string;
|
|
92
|
+
search?: { get(name: string): string | null } | null;
|
|
93
|
+
/**
|
|
94
|
+
* Whether the form is inside a modal. A sign-in there is a DETOUR from the
|
|
95
|
+
* page the shopper is on, so a destination that says nothing — the shipped
|
|
96
|
+
* `/`, or the page they are already on — is read as "nowhere to go" rather
|
|
97
|
+
* than as a navigation.
|
|
98
|
+
*
|
|
99
|
+
* Decided on the VALUE and not on the mode's default, deliberately: the
|
|
100
|
+
* editor writes every select's default into the block it inserts
|
|
101
|
+
* (`getDefaultBlockProperties`), so a host-aware DEFAULT would be dead for
|
|
102
|
+
* every newly authored drawer — it would only ever apply to compositions
|
|
103
|
+
* that predate the property. An authored `/account` still navigates.
|
|
104
|
+
*/
|
|
105
|
+
inModal?: boolean;
|
|
106
|
+
/** The page the shopper is on, so "go where you already are" can be seen. */
|
|
107
|
+
pathname?: string | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Where to go, or `null` for "do not navigate at all" — which is a real answer
|
|
112
|
+
* rather than a missing one, and the one a drawer needs.
|
|
113
|
+
*/
|
|
114
|
+
export const resolveAuthDestination = ({
|
|
115
|
+
mode,
|
|
116
|
+
authored,
|
|
117
|
+
search,
|
|
118
|
+
inModal = false,
|
|
119
|
+
pathname
|
|
120
|
+
}: AuthDestinationInput): string | null => {
|
|
121
|
+
if (mode === 'stay') return null;
|
|
122
|
+
|
|
123
|
+
const fallback = safeInternalPath(authored, '/');
|
|
124
|
+
const resolved = mode === 'return' ? readReturnPath(search) ?? fallback : fallback;
|
|
125
|
+
|
|
126
|
+
// Inside a modal, a destination that names nowhere in particular is nowhere
|
|
127
|
+
// to go — see `inModal`.
|
|
128
|
+
if (inModal && (resolved === '/' || resolved === pathname)) return null;
|
|
129
|
+
|
|
130
|
+
return resolved;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The same question for a shopper who is ALREADY signed in and has landed on a
|
|
135
|
+
* login screen. The url still knows what they were trying to reach, so it is
|
|
136
|
+
* honoured first; `authored` is where a brand sends them otherwise (their
|
|
137
|
+
* account, normally).
|
|
138
|
+
*
|
|
139
|
+
* Never `null`: a signed-in shopper looking at a login form is a dead end, so
|
|
140
|
+
* there is always somewhere better to be.
|
|
141
|
+
*/
|
|
142
|
+
export const resolveSignedInDestination = (
|
|
143
|
+
authored: string,
|
|
144
|
+
search?: { get(name: string): string | null } | null
|
|
145
|
+
): string => readReturnPath(search) ?? safeInternalPath(authored, '/account');
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The path a trigger records so the form can bring the shopper back.
|
|
149
|
+
*
|
|
150
|
+
* Appended by the control that NAVIGATES to a login page — the account button,
|
|
151
|
+
* a "sign in" link — because that control is the only thing that knows where
|
|
152
|
+
* the shopper was standing. Without it the login page has nothing to return
|
|
153
|
+
* to, which is why `return` mode used to be unbuildable: pz's own route guard
|
|
154
|
+
* redirects to the auth page with no parameter at all.
|
|
155
|
+
*/
|
|
156
|
+
export const withReturnPath = (href: string, currentPath?: string): string => {
|
|
157
|
+
if (!isSafeInternalPath(href) || !isSafeInternalPath(currentPath)) return href;
|
|
158
|
+
|
|
159
|
+
// Never twice, and never over an answer the author wrote themselves.
|
|
160
|
+
const [path, query = ''] = href.split('?');
|
|
161
|
+
const params = new URLSearchParams(query);
|
|
162
|
+
|
|
163
|
+
if (AUTH_RETURN_PARAMS.some((param) => params.get(param))) return href;
|
|
164
|
+
|
|
165
|
+
params.set(AUTH_RETURN_PARAMS[0], (currentPath as string).trim());
|
|
166
|
+
|
|
167
|
+
return `${path}?${params.toString()}`;
|
|
168
|
+
};
|
|
@@ -132,6 +132,37 @@ export const collectAuthFieldErrors = (
|
|
|
132
132
|
return { errors, firstInvalid };
|
|
133
133
|
};
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Put the caret where the work is after a SERVER refusal.
|
|
137
|
+
*
|
|
138
|
+
* The client-side collector already does this for what it finds itself
|
|
139
|
+
* (`firstInvalid`), and a refusal that arrives from commerce deserves the same
|
|
140
|
+
* treatment: a duplicate e-mail three fields up is a message the shopper has
|
|
141
|
+
* to scroll for otherwise. Document order rather than the order commerce
|
|
142
|
+
* listed its errors in, because the shopper reads the form, not the response.
|
|
143
|
+
*
|
|
144
|
+
* A control the design took off this breakpoint is skipped for the same reason
|
|
145
|
+
* the collector skips it: focus cannot land on something nobody can see.
|
|
146
|
+
*/
|
|
147
|
+
export const focusFirstAuthFieldError = (
|
|
148
|
+
form: HTMLFormElement | null | undefined,
|
|
149
|
+
errors: AuthFieldErrors
|
|
150
|
+
): void => {
|
|
151
|
+
if (!form || Object.keys(errors).length === 0) return;
|
|
152
|
+
|
|
153
|
+
const controls = Array.from(
|
|
154
|
+
form.querySelectorAll<FormControl>(
|
|
155
|
+
'input[name], select[name], textarea[name]'
|
|
156
|
+
)
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const first = controls.find(
|
|
160
|
+
(control) => Boolean(errors[control.name]) && isVisibleControl(control)
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
first?.focus();
|
|
164
|
+
};
|
|
165
|
+
|
|
135
166
|
/**
|
|
136
167
|
* The same map with one field's message dropped — what a form does the moment
|
|
137
168
|
* that field is edited, so a message never outlives the mistake it describes.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import type { Block } from '../theme-block';
|
|
6
|
+
import { getResponsiveValue } from '../utils';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* WHICH SCREEN a composed form is on, and what is true about it right now.
|
|
10
|
+
*
|
|
11
|
+
* The forms already composed their normal state — fields, message, CTA, all
|
|
12
|
+
* blocks a designer places. Every OTHER state was still the form's own
|
|
13
|
+
* markup: one authored sentence for the confirmation, one for a dead link, one
|
|
14
|
+
* for "checking", and a spinner nobody could dress. So a brand could build its
|
|
15
|
+
* login screen and could not build the screen it turns into.
|
|
16
|
+
*
|
|
17
|
+
* The two halves are separate on purpose, because a shopper's reality is:
|
|
18
|
+
*
|
|
19
|
+
* - `screen` is exclusive — the form is being filled, OR it has finished, OR
|
|
20
|
+
* the link it needs is dead, OR it is still checking that link;
|
|
21
|
+
* - `busy` and `hasError` happen WHILE the form is being filled, so a refusal
|
|
22
|
+
* must not take the fields away with it. A single exclusive state would do
|
|
23
|
+
* exactly that, which is the bug this shape avoids.
|
|
24
|
+
*
|
|
25
|
+
* Read by `form-state` blocks — the container a designer scopes a piece of the
|
|
26
|
+
* composition to. The form itself decides which of those containers to draw,
|
|
27
|
+
* the same way an auth flow decides which of its steps to draw and the OTP
|
|
28
|
+
* form which of its phases: a container decides nothing about its own
|
|
29
|
+
* visibility, so it stays dressable on its own.
|
|
30
|
+
*/
|
|
31
|
+
export interface AuthFormStateValue {
|
|
32
|
+
screen: 'form' | 'success' | 'expired' | 'checking';
|
|
33
|
+
busy: boolean;
|
|
34
|
+
hasError: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const FORM_STATE_KEYS = [
|
|
38
|
+
'form',
|
|
39
|
+
'busy',
|
|
40
|
+
'error',
|
|
41
|
+
'success',
|
|
42
|
+
'expired',
|
|
43
|
+
'checking'
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
export type FormStateKey = (typeof FORM_STATE_KEYS)[number];
|
|
47
|
+
|
|
48
|
+
const IDLE: AuthFormStateValue = {
|
|
49
|
+
screen: 'form',
|
|
50
|
+
busy: false,
|
|
51
|
+
hasError: false
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const AuthFormStateContext = React.createContext<AuthFormStateValue>(IDLE);
|
|
55
|
+
|
|
56
|
+
export const AuthFormStateProvider = AuthFormStateContext.Provider;
|
|
57
|
+
|
|
58
|
+
/** The state of the form this block is inside; the idle form outside one. */
|
|
59
|
+
export const useAuthFormState = (): AuthFormStateValue =>
|
|
60
|
+
React.useContext(AuthFormStateContext);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether a container scoped to `want` shows in `state`.
|
|
64
|
+
*
|
|
65
|
+
* `form` covers being filled INCLUDING while busy or refused — a container
|
|
66
|
+
* holding the fields must not vanish when commerce answers with a message.
|
|
67
|
+
* `busy` and `error` are additive to it rather than instead of it.
|
|
68
|
+
*/
|
|
69
|
+
export const matchesFormState = (
|
|
70
|
+
want: string,
|
|
71
|
+
state: AuthFormStateValue
|
|
72
|
+
): boolean => {
|
|
73
|
+
switch (want) {
|
|
74
|
+
case 'busy':
|
|
75
|
+
return state.screen === 'form' && state.busy;
|
|
76
|
+
case 'error':
|
|
77
|
+
return state.screen === 'form' && state.hasError;
|
|
78
|
+
case 'form':
|
|
79
|
+
return state.screen === 'form';
|
|
80
|
+
case 'success':
|
|
81
|
+
case 'expired':
|
|
82
|
+
case 'checking':
|
|
83
|
+
return state.screen === want;
|
|
84
|
+
// An unrecognised value is a container a newer editor wrote: drawing it
|
|
85
|
+
// everywhere would be worse than drawing it nowhere, since it was scoped
|
|
86
|
+
// deliberately.
|
|
87
|
+
default:
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** The state a container is scoped to, '' when it names none. */
|
|
93
|
+
export const readFormStateKey = (
|
|
94
|
+
block: Block,
|
|
95
|
+
currentBreakpoint = 'desktop'
|
|
96
|
+
): string =>
|
|
97
|
+
String(
|
|
98
|
+
getResponsiveValue(block.properties?.formState, currentBreakpoint, '')
|
|
99
|
+
).trim();
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether the composition takes over one of the form's OWN screens.
|
|
103
|
+
*
|
|
104
|
+
* Asked before the form draws its built-in replacement for that screen: a
|
|
105
|
+
* designer who has composed the confirmation should not get the form's
|
|
106
|
+
* sentence as well, and — since these replacements swap out the whole
|
|
107
|
+
* composition — the container has to be mounted at all to draw anything.
|
|
108
|
+
*
|
|
109
|
+
* Hidden containers do not count, for the same reason a hidden Form Error does
|
|
110
|
+
* not: it draws nothing, so standing the built-in copy down would leave the
|
|
111
|
+
* shopper with no answer whatsoever.
|
|
112
|
+
*/
|
|
113
|
+
export const containsFormStateBlock = (
|
|
114
|
+
blocks: Block[] | undefined,
|
|
115
|
+
want: string,
|
|
116
|
+
currentBreakpoint = 'desktop'
|
|
117
|
+
): boolean =>
|
|
118
|
+
(blocks || []).some(
|
|
119
|
+
(block) =>
|
|
120
|
+
!block.hidden &&
|
|
121
|
+
((block.type === 'form-state' &&
|
|
122
|
+
readFormStateKey(block, currentBreakpoint) === want) ||
|
|
123
|
+
containsFormStateBlock(block.blocks, want, currentBreakpoint))
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
/** Whether a subtree holds the block the designer has selected. */
|
|
127
|
+
const containsBlockId = (
|
|
128
|
+
blocks: Block[] | undefined,
|
|
129
|
+
blockId: string
|
|
130
|
+
): boolean =>
|
|
131
|
+
(blocks || []).some(
|
|
132
|
+
(candidate) =>
|
|
133
|
+
candidate.id === blockId || containsBlockId(candidate.blocks, blockId)
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Which of a form's children to draw in the state it is in.
|
|
138
|
+
*
|
|
139
|
+
* A child that is not a state container is shared chrome — a logo, a title, a
|
|
140
|
+
* support line — and shows on every screen, exactly as a non-phase child of
|
|
141
|
+
* the OTP form does. A container shows when its state matches, and
|
|
142
|
+
* additionally in the canvas while it holds the selected block, so a designer
|
|
143
|
+
* can dress the screen they are not previewing.
|
|
144
|
+
*/
|
|
145
|
+
export const showsInFormState = ({
|
|
146
|
+
block,
|
|
147
|
+
state,
|
|
148
|
+
isDesigner,
|
|
149
|
+
selectedBlockId,
|
|
150
|
+
currentBreakpoint = 'desktop'
|
|
151
|
+
}: {
|
|
152
|
+
block: Block;
|
|
153
|
+
state: AuthFormStateValue;
|
|
154
|
+
isDesigner?: boolean;
|
|
155
|
+
selectedBlockId?: string;
|
|
156
|
+
currentBreakpoint?: string;
|
|
157
|
+
}): boolean => {
|
|
158
|
+
if (block.type !== 'form-state') return true;
|
|
159
|
+
|
|
160
|
+
if (
|
|
161
|
+
isDesigner &&
|
|
162
|
+
selectedBlockId &&
|
|
163
|
+
(block.id === selectedBlockId ||
|
|
164
|
+
containsBlockId(block.blocks, selectedBlockId))
|
|
165
|
+
) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return matchesFormState(readFormStateKey(block, currentBreakpoint), state);
|
|
170
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import type { Block } from '../theme-block';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The sentence a composed form answers a SUCCESS with.
|
|
9
|
+
*
|
|
10
|
+
* The mirror of `form-error-context.tsx`, and it exists for the same reason
|
|
11
|
+
* turned the other way round: a form-level message belongs to the submission
|
|
12
|
+
* rather than to a field, so the form has nowhere good to put it. The error
|
|
13
|
+
* half was solved by publishing the message and letting a block draw it; the
|
|
14
|
+
* success half was still a node the form drew ITSELF, and in the two blocks
|
|
15
|
+
* where it matters — "a reset link is on its way", "your password has been
|
|
16
|
+
* changed" — that node REPLACES the whole form. A designer could recolour it
|
|
17
|
+
* through its style part and nothing more: no icon beside it, no heading above
|
|
18
|
+
* it, no way back except the one built-in link, and no way to move it.
|
|
19
|
+
*
|
|
20
|
+
* With the message published, a `auth-success` block draws it wherever the
|
|
21
|
+
* design puts it, with whatever the design puts around it — because the things
|
|
22
|
+
* around it are then ordinary sibling blocks. The form keeps its built-in node
|
|
23
|
+
* for every composition that has not placed one, so nothing published changes.
|
|
24
|
+
*
|
|
25
|
+
* Only the forms that HAVE a success state provide it: a sign-in has none —
|
|
26
|
+
* the shopper is in and the screen moves on — which is why the login, register
|
|
27
|
+
* and OTP forms are not in this list and the block is not offered inside them.
|
|
28
|
+
*/
|
|
29
|
+
export const AuthFormSuccessContext = React.createContext<string>('');
|
|
30
|
+
|
|
31
|
+
export const AuthFormSuccessProvider = AuthFormSuccessContext.Provider;
|
|
32
|
+
|
|
33
|
+
/** The form's success message, or '' — outside a form and before it finishes. */
|
|
34
|
+
export const useAuthFormSuccess = (): string =>
|
|
35
|
+
React.useContext(AuthFormSuccessContext);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether the blocks a form is ABOUT to draw contain a success block.
|
|
39
|
+
*
|
|
40
|
+
* Two things hang on the answer, which is why it is asked of the rendered
|
|
41
|
+
* subset rather than of every child: the built-in message stands down, and —
|
|
42
|
+
* in the forms whose success state replaces the form entirely — the
|
|
43
|
+
* composition stays on screen so the block placed inside it is still mounted
|
|
44
|
+
* to draw anything at all.
|
|
45
|
+
*
|
|
46
|
+
* A hidden block draws nothing, so it cannot be the place the message goes:
|
|
47
|
+
* counting one would silence the built-in line and leave the shopper with no
|
|
48
|
+
* confirmation whatsoever. Same rule, and the same past bug, as
|
|
49
|
+
* `containsAuthErrorBlock`.
|
|
50
|
+
*/
|
|
51
|
+
export const containsAuthSuccessBlock = (blocks: Block[] | undefined): boolean =>
|
|
52
|
+
(blocks || []).some(
|
|
53
|
+
(block) =>
|
|
54
|
+
!block.hidden &&
|
|
55
|
+
(block.type === 'auth-success' || containsAuthSuccessBlock(block.blocks))
|
|
56
|
+
);
|