@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,717 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useContext, useState } from 'react';
|
|
4
|
+
import { useParams, useSearchParams } from 'next/navigation';
|
|
5
|
+
import {
|
|
6
|
+
usePasswordResetMutation,
|
|
7
|
+
useGetValidatePasswordResetTokenQuery
|
|
8
|
+
} from '@akinon/next/data/client/account';
|
|
9
|
+
import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
|
|
10
|
+
import { readToggle } from './product-block-utils';
|
|
11
|
+
import {
|
|
12
|
+
AuthFieldErrors,
|
|
13
|
+
AuthFieldErrorsProvider,
|
|
14
|
+
clearAuthFieldError,
|
|
15
|
+
collectAuthFieldErrors,
|
|
16
|
+
focusFirstAuthFieldError
|
|
17
|
+
} from '../auth/auth-field-errors';
|
|
18
|
+
import {
|
|
19
|
+
authFormMessage,
|
|
20
|
+
readCommerceError,
|
|
21
|
+
splitByKnownFields
|
|
22
|
+
} from '../auth/read-auth-error';
|
|
23
|
+
import {
|
|
24
|
+
AuthFormErrorProvider,
|
|
25
|
+
containsAuthErrorBlock
|
|
26
|
+
} from '../auth/form-error-context';
|
|
27
|
+
import {
|
|
28
|
+
AuthFormSuccessProvider,
|
|
29
|
+
containsAuthSuccessBlock
|
|
30
|
+
} from '../auth/form-success-context';
|
|
31
|
+
import {
|
|
32
|
+
AuthFormStateProvider,
|
|
33
|
+
AuthFormStateValue,
|
|
34
|
+
containsFormStateBlock,
|
|
35
|
+
showsInFormState
|
|
36
|
+
} from '../auth/form-state-context';
|
|
37
|
+
import {
|
|
38
|
+
clearFormSubmitState,
|
|
39
|
+
publishFormSubmitState
|
|
40
|
+
} from '../auth/form-submit-bus';
|
|
41
|
+
import {
|
|
42
|
+
AuthFlowIdContext,
|
|
43
|
+
resolveAuthStepTarget,
|
|
44
|
+
setAuthStep
|
|
45
|
+
} from '../auth/auth-flow-bus';
|
|
46
|
+
import { renderComposedChildren } from './render-composed-children';
|
|
47
|
+
import { useHostModalCloser } from '../overlays/modal-host-context';
|
|
48
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The two names this form submits under, and therefore the only two a
|
|
52
|
+
* field-level refusal can be hung on.
|
|
53
|
+
*/
|
|
54
|
+
const RESET_PASSWORD_FIELD_NAMES = ['password', 'repeatPassword'] as const;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What commerce calls the two boxes, and what this form calls them.
|
|
58
|
+
*
|
|
59
|
+
* The mutation posts `new_password1` / `new_password2` (see
|
|
60
|
+
* `account.passwordReset`), so a refusal comes back under those names — which
|
|
61
|
+
* no control on screen submits under. Without the map every "this password is
|
|
62
|
+
* too common" would be lifted to the form-level slot as an orphan instead of
|
|
63
|
+
* landing under the box it is about.
|
|
64
|
+
*/
|
|
65
|
+
const SERVER_FIELD_ALIASES: Record<string, string> = {
|
|
66
|
+
new_password1: 'password',
|
|
67
|
+
new_password2: 'repeatPassword'
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const RESET_PREVIEW_STATES = [
|
|
71
|
+
'form',
|
|
72
|
+
'success',
|
|
73
|
+
'expired',
|
|
74
|
+
'checking'
|
|
75
|
+
] as const;
|
|
76
|
+
|
|
77
|
+
type ResetPreviewState = (typeof RESET_PREVIEW_STATES)[number];
|
|
78
|
+
|
|
79
|
+
const TOKEN_SOURCES = ['path', 'query'] as const;
|
|
80
|
+
|
|
81
|
+
/** The token, as the reset mail's link carries it. */
|
|
82
|
+
const readToken = (
|
|
83
|
+
source: (typeof TOKEN_SOURCES)[number],
|
|
84
|
+
params: Record<string, string | string[] | undefined>,
|
|
85
|
+
search: URLSearchParams | null,
|
|
86
|
+
queryKey: string
|
|
87
|
+
): string => {
|
|
88
|
+
if (source === 'query') return (search?.get(queryKey) || '').trim();
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A catch-all route segment, joined back into the token commerce issued:
|
|
92
|
+
* pz's own route is `/users/reset/[[...id]]`, and the token contains
|
|
93
|
+
* slashes, so the segments have to be put back together. The three names are
|
|
94
|
+
* the ones brands actually use for that segment; a brand whose segment is
|
|
95
|
+
* named otherwise points the block at the query instead.
|
|
96
|
+
*/
|
|
97
|
+
const raw = params.id ?? params.token ?? params.slug;
|
|
98
|
+
|
|
99
|
+
return (Array.isArray(raw) ? raw.join('/') : raw || '').trim();
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Functional auth block: the screen the password-reset MAIL lands on — the two
|
|
104
|
+
* new-password boxes, the link's own validity, and what the shopper sees when
|
|
105
|
+
* it has expired or when the password has been changed.
|
|
106
|
+
*
|
|
107
|
+
* The other half of the reset journey was the half the editor could not see.
|
|
108
|
+
* `forgot-password` composes the request, and then handed the shopper to a page
|
|
109
|
+
* every brand hand-wrote: the same two inputs, the same four states, restyled
|
|
110
|
+
* per brand and drifting from the drawer they started in. This block is that
|
|
111
|
+
* page, composable the same way every other auth form is.
|
|
112
|
+
*
|
|
113
|
+
* Composable: with children the form draws THEM — an `auth-field` per box, the
|
|
114
|
+
* message a Form Error block, the CTA an ordinary Button — and reads the values
|
|
115
|
+
* back out of `FormData` on submit. Left empty it draws its own two rows, so a
|
|
116
|
+
* brand that only wants the screen gets it without composing anything.
|
|
117
|
+
*
|
|
118
|
+
* The three states that are not the form are authored copy, and each can hand
|
|
119
|
+
* the journey to a STEP instead: an expired link belongs back at the
|
|
120
|
+
* `forgot-password` step (a fresh mail is the only way forward), and a changed
|
|
121
|
+
* password belongs wherever the brand wants the shopper next. That is the same
|
|
122
|
+
* handoff `forgot-password` and `register-form` already use, and it is why
|
|
123
|
+
* those states need no blocks of their own.
|
|
124
|
+
*
|
|
125
|
+
* Properties: submitLabel, busyLabel, showLabels, passwordLabel,
|
|
126
|
+
* passwordPlaceholder, repeatPasswordLabel, repeatPasswordPlaceholder,
|
|
127
|
+
* requiredMessage, invalidMessage, mismatchMessage, errorLabel, successLabel,
|
|
128
|
+
* successStepTarget, expiredLabel, expiredStepTarget, checkingLabel,
|
|
129
|
+
* backLabel, backStepTarget, tokenSource, tokenQueryKey, previewState,
|
|
130
|
+
* formHtmlId, showSubmit.
|
|
131
|
+
* Parts: form, field, label, input, error, submit, success, expired, checking,
|
|
132
|
+
* back.
|
|
133
|
+
*/
|
|
134
|
+
const ResetPasswordBlock = ({
|
|
135
|
+
block,
|
|
136
|
+
placeholderId,
|
|
137
|
+
sectionId,
|
|
138
|
+
selectedBlockId,
|
|
139
|
+
currentBreakpoint = 'desktop',
|
|
140
|
+
isDesigner = false
|
|
141
|
+
}: BlockRendererProps) => {
|
|
142
|
+
const prop = (key: string, fallback: string) =>
|
|
143
|
+
String(
|
|
144
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Whether finishing closes the modal this block is inside. OFF by default
|
|
149
|
+
* here, because this block's success state is something the shopper is meant
|
|
150
|
+
* to read — see `useHostModalCloser`.
|
|
151
|
+
*/
|
|
152
|
+
const closeHostModalOnSuccess = readToggle(
|
|
153
|
+
block.properties?.closeHostModalOnSuccess,
|
|
154
|
+
currentBreakpoint,
|
|
155
|
+
false
|
|
156
|
+
);
|
|
157
|
+
const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
const submitLabel = prop('submitLabel', 'Save new password');
|
|
161
|
+
const busyLabel = prop('busyLabel', '');
|
|
162
|
+
const showLabels = readToggle(
|
|
163
|
+
block.properties?.showLabels,
|
|
164
|
+
currentBreakpoint,
|
|
165
|
+
true
|
|
166
|
+
);
|
|
167
|
+
const passwordLabel = prop('passwordLabel', 'New password');
|
|
168
|
+
const passwordPlaceholder = prop('passwordPlaceholder', '');
|
|
169
|
+
const repeatPasswordLabel = prop('repeatPasswordLabel', 'New password again');
|
|
170
|
+
const repeatPasswordPlaceholder = prop('repeatPasswordPlaceholder', '');
|
|
171
|
+
|
|
172
|
+
const requiredMessage = prop('requiredMessage', 'This field is required.');
|
|
173
|
+
const invalidMessage = prop('invalidMessage', 'Please check this field.');
|
|
174
|
+
/**
|
|
175
|
+
* The one rule this form checks that no `required` can express, and the
|
|
176
|
+
* reason it is checked at all: commerce answers a mismatch with a refusal
|
|
177
|
+
* the shopper has to read at the bottom of a round trip, when the browser
|
|
178
|
+
* could have said it before the request left.
|
|
179
|
+
*/
|
|
180
|
+
const mismatchMessage = prop(
|
|
181
|
+
'mismatchMessage',
|
|
182
|
+
'The two passwords do not match.'
|
|
183
|
+
);
|
|
184
|
+
const errorLabel = prop(
|
|
185
|
+
'errorLabel',
|
|
186
|
+
'Could not set the new password. Please try again.'
|
|
187
|
+
);
|
|
188
|
+
const successLabel = prop('successLabel', 'Your password has been changed.');
|
|
189
|
+
const expiredLabel = prop(
|
|
190
|
+
'expiredLabel',
|
|
191
|
+
'This link has expired or has already been used.'
|
|
192
|
+
);
|
|
193
|
+
/**
|
|
194
|
+
* Empty on purpose: a link that is still being checked is a fraction of a
|
|
195
|
+
* second on a warm connection, and a sentence that appears and vanishes is
|
|
196
|
+
* worse than nothing. A brand that wants one types it.
|
|
197
|
+
*/
|
|
198
|
+
const checkingLabel = prop('checkingLabel', '');
|
|
199
|
+
const backLabel = prop('backLabel', 'Back to log in');
|
|
200
|
+
const backStepTarget = prop('backStepTarget', '').trim();
|
|
201
|
+
const successStepTarget = prop('successStepTarget', '').trim();
|
|
202
|
+
/**
|
|
203
|
+
* Where an expired link sends the shopper. A step holding a
|
|
204
|
+
* `forgot-password` block is the only useful destination — a new mail is the
|
|
205
|
+
* only way forward — which is exactly what a flow can express and this block
|
|
206
|
+
* cannot.
|
|
207
|
+
*/
|
|
208
|
+
const expiredStepTarget = prop('expiredStepTarget', '').trim();
|
|
209
|
+
|
|
210
|
+
const tokenSource = resolveOption<(typeof TOKEN_SOURCES)[number]>(
|
|
211
|
+
block.properties?.tokenSource,
|
|
212
|
+
currentBreakpoint,
|
|
213
|
+
'path',
|
|
214
|
+
TOKEN_SOURCES as unknown as (typeof TOKEN_SOURCES)[number][]
|
|
215
|
+
);
|
|
216
|
+
const tokenQueryKey = prop('tokenQueryKey', 'token').trim() || 'token';
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Which state the canvas shows. Three of the four are unreachable in the
|
|
220
|
+
* editor — they need a real token, a real refusal or a real success — so
|
|
221
|
+
* without this a designer could style the form and nothing else.
|
|
222
|
+
*/
|
|
223
|
+
const previewState = resolveOption<ResetPreviewState>(
|
|
224
|
+
block.properties?.previewState,
|
|
225
|
+
currentBreakpoint,
|
|
226
|
+
'form',
|
|
227
|
+
RESET_PREVIEW_STATES as unknown as ResetPreviewState[]
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const formHtmlId = prop('formHtmlId', '').trim();
|
|
231
|
+
const showSubmit = readToggle(
|
|
232
|
+
block.properties?.showSubmit,
|
|
233
|
+
currentBreakpoint,
|
|
234
|
+
true
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const contextFlowId = useContext(AuthFlowIdContext);
|
|
238
|
+
const backStep = resolveAuthStepTarget(backStepTarget, contextFlowId);
|
|
239
|
+
const successStep = resolveAuthStepTarget(successStepTarget, contextFlowId);
|
|
240
|
+
const expiredStep = resolveAuthStepTarget(expiredStepTarget, contextFlowId);
|
|
241
|
+
|
|
242
|
+
const params = useParams() as Record<string, string | string[] | undefined>;
|
|
243
|
+
const search = useSearchParams();
|
|
244
|
+
const token = readToken(tokenSource, params || {}, search, tokenQueryKey);
|
|
245
|
+
|
|
246
|
+
const childBlocks = (block.blocks || [])
|
|
247
|
+
.filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
|
|
248
|
+
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
249
|
+
const isComposed = childBlocks.length > 0;
|
|
250
|
+
/**
|
|
251
|
+
* Whether the composition draws the confirmation itself, with a Form Success
|
|
252
|
+
* block — in which case the form is not replaced by the built-in copy, since
|
|
253
|
+
* a block inside a form that has been swapped out is unmounted and draws
|
|
254
|
+
* nothing. See `auth/form-success-context.tsx`.
|
|
255
|
+
*/
|
|
256
|
+
/**
|
|
257
|
+
* Which of this block's four screens the COMPOSITION draws itself, in a Form
|
|
258
|
+
* State container. Each one the composition owns is a built-in replacement
|
|
259
|
+
* that stands down — and it has to, since those replacements swap out the
|
|
260
|
+
* whole composition and would unmount the very container meant to draw the
|
|
261
|
+
* screen.
|
|
262
|
+
*/
|
|
263
|
+
const composedScreen = (want: string) =>
|
|
264
|
+
isComposed && containsFormStateBlock(childBlocks, want, currentBreakpoint);
|
|
265
|
+
const successScreenIsComposed = composedScreen('success');
|
|
266
|
+
const successIsAuthored =
|
|
267
|
+
successScreenIsComposed ||
|
|
268
|
+
(isComposed && containsAuthSuccessBlock(childBlocks));
|
|
269
|
+
|
|
270
|
+
const [values, setValues] = useState({ password: '', repeatPassword: '' });
|
|
271
|
+
const [error, setError] = useState('');
|
|
272
|
+
const [fieldErrors, setFieldErrors] = useState<AuthFieldErrors>({});
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The link's own validity, asked before the shopper is invited to type
|
|
276
|
+
* anything. Skipped in the canvas — the designer has no token, and asking
|
|
277
|
+
* commerce about one would answer "expired" for every composition.
|
|
278
|
+
*/
|
|
279
|
+
const {
|
|
280
|
+
data: validation,
|
|
281
|
+
isLoading: isValidating,
|
|
282
|
+
isError: validationFailed
|
|
283
|
+
} = useGetValidatePasswordResetTokenQuery(token, {
|
|
284
|
+
skip: !token || isDesigner
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const [passwordReset, { isLoading, isSuccess }] = usePasswordResetMutation();
|
|
288
|
+
|
|
289
|
+
const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
|
|
290
|
+
const target = event.target as { name?: string } | null;
|
|
291
|
+
|
|
292
|
+
setFieldErrors((current) =>
|
|
293
|
+
clearAuthFieldError(current, target?.name || '')
|
|
294
|
+
);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* A detached CTA (a Button carrying this form's id) is outside this subtree,
|
|
299
|
+
* so the busy state travels through the module bus rather than through
|
|
300
|
+
* props. Same contract as the other auth forms.
|
|
301
|
+
*/
|
|
302
|
+
React.useEffect(() => {
|
|
303
|
+
if (!formHtmlId) return undefined;
|
|
304
|
+
|
|
305
|
+
publishFormSubmitState(formHtmlId, isLoading);
|
|
306
|
+
|
|
307
|
+
return () => clearFormSubmitState(formHtmlId);
|
|
308
|
+
}, [formHtmlId, isLoading]);
|
|
309
|
+
|
|
310
|
+
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
311
|
+
event.preventDefault();
|
|
312
|
+
setError('');
|
|
313
|
+
|
|
314
|
+
// Submitting inside the designer would spend the shopper's real token.
|
|
315
|
+
// `isSuccess` closes the one path that survives the success state: the
|
|
316
|
+
// composed form stays on screen when it draws its own confirmation, so a
|
|
317
|
+
// DETACHED CTA — a Button carrying this form's id, outside this subtree and
|
|
318
|
+
// therefore outside the branch that drops the built-in one — could still
|
|
319
|
+
// submit a token that has already been spent.
|
|
320
|
+
if (isDesigner || isLoading || isSuccess) return;
|
|
321
|
+
|
|
322
|
+
// Held onto because `currentTarget` is only the form for as long as the
|
|
323
|
+
// handler runs, and the refusal is read after an await.
|
|
324
|
+
const formElement = event.currentTarget;
|
|
325
|
+
|
|
326
|
+
const { errors: nextFieldErrors, firstInvalid } = collectAuthFieldErrors(
|
|
327
|
+
formElement,
|
|
328
|
+
{ required: requiredMessage, invalid: invalidMessage }
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const entries = new FormData(formElement);
|
|
332
|
+
const typed = isComposed
|
|
333
|
+
? {
|
|
334
|
+
password: String(entries.get('password') ?? ''),
|
|
335
|
+
repeatPassword: String(entries.get('repeatPassword') ?? '')
|
|
336
|
+
}
|
|
337
|
+
: values;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The mismatch, checked only once both boxes are filled: telling a shopper
|
|
341
|
+
* the passwords do not match while they are still typing the second one is
|
|
342
|
+
* a message about work in progress.
|
|
343
|
+
*/
|
|
344
|
+
const mismatched =
|
|
345
|
+
Boolean(typed.password) &&
|
|
346
|
+
Boolean(typed.repeatPassword) &&
|
|
347
|
+
typed.password !== typed.repeatPassword;
|
|
348
|
+
|
|
349
|
+
const withMismatch = mismatched
|
|
350
|
+
? { ...nextFieldErrors, repeatPassword: mismatchMessage }
|
|
351
|
+
: nextFieldErrors;
|
|
352
|
+
|
|
353
|
+
setFieldErrors(withMismatch);
|
|
354
|
+
|
|
355
|
+
if (firstInvalid) {
|
|
356
|
+
firstInvalid.focus();
|
|
357
|
+
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (mismatched) {
|
|
362
|
+
focusFirstAuthFieldError(formElement, withMismatch);
|
|
363
|
+
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
await passwordReset({
|
|
369
|
+
password: typed.password,
|
|
370
|
+
repeatPassword: typed.repeatPassword,
|
|
371
|
+
slug: token
|
|
372
|
+
}).unwrap();
|
|
373
|
+
|
|
374
|
+
if (successStep) setAuthStep(successStep.flowId, successStep.stepKey);
|
|
375
|
+
|
|
376
|
+
// Same rule as the forgot-password form: off by default, because
|
|
377
|
+
// "your password has been changed" is what the shopper came for.
|
|
378
|
+
closeHostModal();
|
|
379
|
+
} catch (rejection) {
|
|
380
|
+
/**
|
|
381
|
+
* What commerce actually refused — a password policy, a reused password,
|
|
382
|
+
* a token that expired between the check and the submit — routed to the
|
|
383
|
+
* box it is about through the alias map above.
|
|
384
|
+
*/
|
|
385
|
+
const reading = readCommerceError((rejection as { data?: unknown })?.data);
|
|
386
|
+
const aliased = Object.entries(reading.fieldMessages).reduce(
|
|
387
|
+
(acc, [name, message]) => {
|
|
388
|
+
acc[SERVER_FIELD_ALIASES[name] || name] = message;
|
|
389
|
+
|
|
390
|
+
return acc;
|
|
391
|
+
},
|
|
392
|
+
{} as Record<string, string>
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const attributed = splitByKnownFields(
|
|
396
|
+
{ ...reading, fieldMessages: aliased },
|
|
397
|
+
formElement,
|
|
398
|
+
RESET_PASSWORD_FIELD_NAMES
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
setFieldErrors((current) => ({ ...current, ...attributed.fieldMessages }));
|
|
402
|
+
focusFirstAuthFieldError(formElement, attributed.fieldMessages);
|
|
403
|
+
setError(authFormMessage(attributed, errorLabel));
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const stepControl = (
|
|
408
|
+
step: { flowId: string; stepKey: string } | null,
|
|
409
|
+
label: string,
|
|
410
|
+
marker: string
|
|
411
|
+
) =>
|
|
412
|
+
step ? (
|
|
413
|
+
<button
|
|
414
|
+
type="button"
|
|
415
|
+
{...{ [marker]: '' }}
|
|
416
|
+
{...partAttrs('back')}
|
|
417
|
+
onClick={() => {
|
|
418
|
+
// The canvas has its own step switcher; a click here must not move
|
|
419
|
+
// the flow under the designer's feet.
|
|
420
|
+
if (isDesigner) return;
|
|
421
|
+
|
|
422
|
+
setAuthStep(step.flowId, step.stepKey);
|
|
423
|
+
}}
|
|
424
|
+
style={{
|
|
425
|
+
alignSelf: 'flex-start',
|
|
426
|
+
padding: 0,
|
|
427
|
+
margin: 0,
|
|
428
|
+
border: 'none',
|
|
429
|
+
background: 'transparent',
|
|
430
|
+
color: 'inherit',
|
|
431
|
+
font: 'inherit',
|
|
432
|
+
textDecoration: 'underline',
|
|
433
|
+
cursor: 'pointer'
|
|
434
|
+
}}
|
|
435
|
+
>
|
|
436
|
+
{label}
|
|
437
|
+
</button>
|
|
438
|
+
) : null;
|
|
439
|
+
|
|
440
|
+
const shows = (state: ResetPreviewState) =>
|
|
441
|
+
isDesigner ? previewState === state : false;
|
|
442
|
+
|
|
443
|
+
/** Changed — for real on a storefront, on the preview select in the canvas. */
|
|
444
|
+
const inSuccessState =
|
|
445
|
+
(!isDesigner && isSuccess && !successStep) || shows('success');
|
|
446
|
+
|
|
447
|
+
// Changed. The form is replaced rather than left on screen: the token is
|
|
448
|
+
// spent, so submitting it again could only fail. A composition that placed a
|
|
449
|
+
// Form Success block keeps its own layout instead, and loses the submit for
|
|
450
|
+
// the same reason the replacement existed — see below.
|
|
451
|
+
if (inSuccessState && !successIsAuthored) {
|
|
452
|
+
return (
|
|
453
|
+
<div
|
|
454
|
+
data-reset-password=""
|
|
455
|
+
className="w-full"
|
|
456
|
+
data-reset-password-state="success"
|
|
457
|
+
{...partAttrs('success')}
|
|
458
|
+
role="status"
|
|
459
|
+
>
|
|
460
|
+
{successLabel}
|
|
461
|
+
{stepControl(backStep, backLabel, 'data-reset-password-back')}
|
|
462
|
+
</div>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// No token at all is the same dead end as a spent one — the shopper reached
|
|
467
|
+
// this screen without a link — so it is answered the same way.
|
|
468
|
+
const linkIsDead =
|
|
469
|
+
!isDesigner &&
|
|
470
|
+
!isValidating &&
|
|
471
|
+
(!token || validationFailed || validation?.validlink === false);
|
|
472
|
+
|
|
473
|
+
if ((linkIsDead || shows('expired')) && !composedScreen('expired')) {
|
|
474
|
+
return (
|
|
475
|
+
<div
|
|
476
|
+
data-reset-password=""
|
|
477
|
+
className="w-full"
|
|
478
|
+
data-reset-password-state="expired"
|
|
479
|
+
{...partAttrs('expired')}
|
|
480
|
+
role="alert"
|
|
481
|
+
>
|
|
482
|
+
{expiredLabel}
|
|
483
|
+
{stepControl(expiredStep, backLabel, 'data-reset-password-expired-back')}
|
|
484
|
+
</div>
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (
|
|
489
|
+
((!isDesigner && isValidating) || shows('checking')) &&
|
|
490
|
+
!composedScreen('checking')
|
|
491
|
+
) {
|
|
492
|
+
// Nothing at all unless the brand authored a sentence: see `checkingLabel`.
|
|
493
|
+
if (!checkingLabel) return null;
|
|
494
|
+
|
|
495
|
+
return (
|
|
496
|
+
<div
|
|
497
|
+
data-reset-password=""
|
|
498
|
+
className="w-full"
|
|
499
|
+
data-reset-password-state="checking"
|
|
500
|
+
{...partAttrs('checking')}
|
|
501
|
+
role="status"
|
|
502
|
+
aria-busy
|
|
503
|
+
>
|
|
504
|
+
{checkingLabel}
|
|
505
|
+
</div>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* What the composition's Form State containers read. The screen is exclusive
|
|
511
|
+
* — the shopper is on one of the four — while `busy` and `hasError` sit
|
|
512
|
+
* beside it, so a refusal does not take the two boxes away with it.
|
|
513
|
+
*/
|
|
514
|
+
const formStateValue: AuthFormStateValue = {
|
|
515
|
+
screen: inSuccessState
|
|
516
|
+
? 'success'
|
|
517
|
+
: linkIsDead || shows('expired')
|
|
518
|
+
? 'expired'
|
|
519
|
+
: (!isDesigner && isValidating) || shows('checking')
|
|
520
|
+
? 'checking'
|
|
521
|
+
: 'form',
|
|
522
|
+
busy: isLoading,
|
|
523
|
+
hasError: Boolean(error)
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const formStyle: React.CSSProperties = {
|
|
527
|
+
display: 'flex',
|
|
528
|
+
flexDirection: 'column',
|
|
529
|
+
gap: 'var(--reset-password-gap, 12px)'
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const submitButton = showSubmit ? (
|
|
533
|
+
<button
|
|
534
|
+
type="submit"
|
|
535
|
+
data-reset-password-submit=""
|
|
536
|
+
{...partAttrs('submit', { loading: isLoading, disabled: isLoading })}
|
|
537
|
+
disabled={isLoading}
|
|
538
|
+
aria-busy={isLoading}
|
|
539
|
+
style={{
|
|
540
|
+
padding: '12px 20px',
|
|
541
|
+
background: 'var(--reset-password-cta-bg, #111827)',
|
|
542
|
+
color: 'var(--reset-password-cta-color, #ffffff)',
|
|
543
|
+
border: 'none',
|
|
544
|
+
cursor: isLoading ? 'not-allowed' : 'pointer',
|
|
545
|
+
opacity: isLoading ? 0.7 : 1
|
|
546
|
+
}}
|
|
547
|
+
>
|
|
548
|
+
{isLoading && busyLabel ? busyLabel : submitLabel}
|
|
549
|
+
</button>
|
|
550
|
+
) : null;
|
|
551
|
+
|
|
552
|
+
if (isComposed) {
|
|
553
|
+
const errorIsAuthored = containsAuthErrorBlock(childBlocks);
|
|
554
|
+
|
|
555
|
+
return (
|
|
556
|
+
<AuthFormErrorProvider value={error}>
|
|
557
|
+
{/* The confirmation, for the block the composition placed for it. Empty
|
|
558
|
+
until the password has actually been changed. */}
|
|
559
|
+
<AuthFormSuccessProvider value={inSuccessState ? successLabel : ''}>
|
|
560
|
+
<AuthFormStateProvider value={formStateValue}>
|
|
561
|
+
<AuthFieldErrorsProvider value={fieldErrors}>
|
|
562
|
+
<form
|
|
563
|
+
id={formHtmlId || undefined}
|
|
564
|
+
data-reset-password=""
|
|
565
|
+
data-reset-password-composed=""
|
|
566
|
+
className="w-full"
|
|
567
|
+
data-reset-password-state={
|
|
568
|
+
inSuccessState ? 'success' : isLoading ? 'busy' : 'idle'
|
|
569
|
+
}
|
|
570
|
+
{...partAttrs('form')}
|
|
571
|
+
onSubmit={submit}
|
|
572
|
+
noValidate
|
|
573
|
+
onInput={handleInput}
|
|
574
|
+
onChange={handleInput}
|
|
575
|
+
style={formStyle}
|
|
576
|
+
>
|
|
577
|
+
{renderComposedChildren({
|
|
578
|
+
// Only the containers this screen calls for; a child that is not
|
|
579
|
+
// a container is shared chrome and shows on all four.
|
|
580
|
+
blocks: childBlocks.filter((childBlock) =>
|
|
581
|
+
showsInFormState({
|
|
582
|
+
block: childBlock,
|
|
583
|
+
state: formStateValue,
|
|
584
|
+
isDesigner,
|
|
585
|
+
selectedBlockId,
|
|
586
|
+
currentBreakpoint
|
|
587
|
+
})
|
|
588
|
+
),
|
|
589
|
+
placeholderId,
|
|
590
|
+
sectionId,
|
|
591
|
+
isDesigner,
|
|
592
|
+
selectedBlockId,
|
|
593
|
+
currentBreakpoint
|
|
594
|
+
})}
|
|
595
|
+
|
|
596
|
+
{/* The fallback for a composition that has not placed a Form Error
|
|
597
|
+
block; see `auth/form-error-context.tsx`. */}
|
|
598
|
+
{error && !errorIsAuthored && (
|
|
599
|
+
<div
|
|
600
|
+
data-reset-password-error=""
|
|
601
|
+
{...partAttrs('error')}
|
|
602
|
+
role="alert"
|
|
603
|
+
>
|
|
604
|
+
{error}
|
|
605
|
+
</div>
|
|
606
|
+
)}
|
|
607
|
+
|
|
608
|
+
{/* Only on the form itself: the token is spent once the password
|
|
609
|
+
is changed and there is nothing to submit on a dead or
|
|
610
|
+
unchecked link, so a button there could only fail. */}
|
|
611
|
+
{formStateValue.screen === 'form' && submitButton}
|
|
612
|
+
|
|
613
|
+
{/* The authored way back. Not when a container owns the screen —
|
|
614
|
+
the way onward is then part of the composition. */}
|
|
615
|
+
{inSuccessState &&
|
|
616
|
+
!successScreenIsComposed &&
|
|
617
|
+
stepControl(backStep, backLabel, 'data-reset-password-back')}
|
|
618
|
+
</form>
|
|
619
|
+
</AuthFieldErrorsProvider>
|
|
620
|
+
</AuthFormStateProvider>
|
|
621
|
+
</AuthFormSuccessProvider>
|
|
622
|
+
</AuthFormErrorProvider>
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const row = (
|
|
627
|
+
name: 'password' | 'repeatPassword',
|
|
628
|
+
label: string,
|
|
629
|
+
placeholder: string,
|
|
630
|
+
autoComplete: string
|
|
631
|
+
) => (
|
|
632
|
+
<label
|
|
633
|
+
data-reset-password-field={name}
|
|
634
|
+
{...partAttrs('field')}
|
|
635
|
+
style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
|
|
636
|
+
>
|
|
637
|
+
{/* Hiding the caption must not cost the input its accessible name, so the
|
|
638
|
+
label text moves onto aria-label instead of disappearing. */}
|
|
639
|
+
{showLabels && <span {...partAttrs('label')}>{label}</span>}
|
|
640
|
+
<input
|
|
641
|
+
type="password"
|
|
642
|
+
name={name}
|
|
643
|
+
value={values[name]}
|
|
644
|
+
onChange={(event) =>
|
|
645
|
+
setValues((current) => ({ ...current, [name]: event.target.value }))
|
|
646
|
+
}
|
|
647
|
+
// `new-password` on both: this screen is where a password manager
|
|
648
|
+
// should offer to GENERATE one and save what it generated, which it
|
|
649
|
+
// will not do for a field that claims to be the current password.
|
|
650
|
+
autoComplete={autoComplete}
|
|
651
|
+
placeholder={placeholder || undefined}
|
|
652
|
+
aria-label={showLabels ? undefined : label}
|
|
653
|
+
aria-invalid={fieldErrors[name] ? true : undefined}
|
|
654
|
+
required
|
|
655
|
+
{...partAttrs('input')}
|
|
656
|
+
style={{
|
|
657
|
+
width: '100%',
|
|
658
|
+
border: `1px solid ${
|
|
659
|
+
fieldErrors[name]
|
|
660
|
+
? 'var(--reset-password-border-invalid, #dc2626)'
|
|
661
|
+
: 'var(--reset-password-border, #d1d5db)'
|
|
662
|
+
}`,
|
|
663
|
+
background: 'transparent',
|
|
664
|
+
color: 'inherit',
|
|
665
|
+
padding: '10px 12px',
|
|
666
|
+
outline: 'none'
|
|
667
|
+
}}
|
|
668
|
+
/>
|
|
669
|
+
{fieldErrors[name] && (
|
|
670
|
+
<span
|
|
671
|
+
data-reset-password-field-error={name}
|
|
672
|
+
{...partAttrs('error')}
|
|
673
|
+
role="alert"
|
|
674
|
+
style={{
|
|
675
|
+
color: 'var(--reset-password-error-color, #dc2626)',
|
|
676
|
+
fontSize: '0.875em'
|
|
677
|
+
}}
|
|
678
|
+
>
|
|
679
|
+
{fieldErrors[name]}
|
|
680
|
+
</span>
|
|
681
|
+
)}
|
|
682
|
+
</label>
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
return (
|
|
686
|
+
<form
|
|
687
|
+
id={formHtmlId || undefined}
|
|
688
|
+
data-reset-password=""
|
|
689
|
+
className="w-full"
|
|
690
|
+
data-reset-password-state={isLoading ? 'busy' : 'idle'}
|
|
691
|
+
{...partAttrs('form')}
|
|
692
|
+
onSubmit={submit}
|
|
693
|
+
noValidate
|
|
694
|
+
onInput={handleInput}
|
|
695
|
+
onChange={handleInput}
|
|
696
|
+
style={formStyle}
|
|
697
|
+
>
|
|
698
|
+
{row('password', passwordLabel, passwordPlaceholder, 'new-password')}
|
|
699
|
+
{row(
|
|
700
|
+
'repeatPassword',
|
|
701
|
+
repeatPasswordLabel,
|
|
702
|
+
repeatPasswordPlaceholder,
|
|
703
|
+
'new-password'
|
|
704
|
+
)}
|
|
705
|
+
|
|
706
|
+
{error && (
|
|
707
|
+
<div data-reset-password-error="" {...partAttrs('error')} role="alert">
|
|
708
|
+
{error}
|
|
709
|
+
</div>
|
|
710
|
+
)}
|
|
711
|
+
|
|
712
|
+
{submitButton}
|
|
713
|
+
</form>
|
|
714
|
+
);
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
export default ResetPasswordBlock;
|