@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
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useContext, useState } from 'react';
|
|
3
|
+
import React, { useContext, useRef, useState } from 'react';
|
|
4
4
|
import NextLink from 'next/link';
|
|
5
|
-
import { useRouter } from 'next/navigation';
|
|
6
|
-
import { signIn } from 'next-auth/react';
|
|
5
|
+
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
6
|
+
import { signIn, useSession } from 'next-auth/react';
|
|
7
7
|
import { useLocalization } from '@akinon/next/hooks';
|
|
8
|
-
import { getResponsiveValue, partAttrs } from '../utils';
|
|
8
|
+
import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
|
|
9
9
|
import {
|
|
10
10
|
AuthFieldErrors,
|
|
11
11
|
AuthFieldErrorsProvider,
|
|
12
12
|
clearAuthFieldError,
|
|
13
|
-
collectAuthFieldErrors
|
|
13
|
+
collectAuthFieldErrors,
|
|
14
|
+
focusFirstAuthFieldError
|
|
14
15
|
} from '../auth/auth-field-errors';
|
|
15
16
|
import {
|
|
16
17
|
clearFormSubmitState,
|
|
@@ -20,61 +21,58 @@ import {
|
|
|
20
21
|
AuthFormErrorProvider,
|
|
21
22
|
containsAuthErrorBlock
|
|
22
23
|
} from '../auth/form-error-context';
|
|
24
|
+
import {
|
|
25
|
+
AuthFormStateProvider,
|
|
26
|
+
AuthFormStateValue,
|
|
27
|
+
showsInFormState
|
|
28
|
+
} from '../auth/form-state-context';
|
|
23
29
|
import { emitCommerceEvent } from '../utils/analytics';
|
|
24
30
|
import { readToggle } from './product-block-utils';
|
|
25
31
|
import { closeAllModals } from '../overlays/overlay-bus';
|
|
32
|
+
import {
|
|
33
|
+
useHostModalCloser,
|
|
34
|
+
useModalHostId
|
|
35
|
+
} from '../overlays/modal-host-context';
|
|
26
36
|
import {
|
|
27
37
|
AuthFlowIdContext,
|
|
28
38
|
resolveAuthStepTarget,
|
|
29
39
|
setAuthStep
|
|
30
40
|
} from '../auth/auth-flow-bus';
|
|
31
41
|
import { renderComposedChildren } from './render-composed-children';
|
|
42
|
+
import {
|
|
43
|
+
authFormMessage,
|
|
44
|
+
readAuthError,
|
|
45
|
+
splitByKnownFields
|
|
46
|
+
} from '../auth/read-auth-error';
|
|
47
|
+
import {
|
|
48
|
+
AUTH_AFTER_SIGN_IN_MODES,
|
|
49
|
+
AuthAfterSignIn,
|
|
50
|
+
resolveAuthDestination,
|
|
51
|
+
resolveSignedInDestination
|
|
52
|
+
} from '../auth/auth-destination';
|
|
53
|
+
import {
|
|
54
|
+
AuthCaptchaApi,
|
|
55
|
+
AuthCaptchaHost,
|
|
56
|
+
AuthCaptchaSlot,
|
|
57
|
+
containsCaptchaBlock
|
|
58
|
+
} from '../auth/auth-captcha';
|
|
32
59
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
33
60
|
|
|
34
|
-
/**
|
|
35
|
-
* Extracts a readable message out of the next-auth error string. pz brands
|
|
36
|
-
* serialise an AuthError[] into `error`, but a plain string is also possible,
|
|
37
|
-
* so both shapes are handled.
|
|
38
|
-
*/
|
|
39
|
-
const readAuthError = (error?: string | null): string => {
|
|
40
|
-
if (!error) return '';
|
|
41
|
-
|
|
42
|
-
try {
|
|
43
|
-
const parsed = JSON.parse(error);
|
|
44
|
-
|
|
45
|
-
if (!Array.isArray(parsed)) return String(error);
|
|
46
|
-
|
|
47
|
-
const messages: string[] = [];
|
|
48
|
-
|
|
49
|
-
parsed.forEach((entry) => {
|
|
50
|
-
const data = (entry as { data?: unknown })?.data;
|
|
51
|
-
|
|
52
|
-
if (Array.isArray(data)) {
|
|
53
|
-
data.forEach((item) => {
|
|
54
|
-
if (typeof item === 'string') {
|
|
55
|
-
messages.push(item);
|
|
56
|
-
} else if (item && typeof item === 'object') {
|
|
57
|
-
const value = (item as { value?: unknown }).value;
|
|
58
|
-
if (typeof value === 'string') messages.push(value);
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
} else if (typeof data === 'string') {
|
|
62
|
-
messages.push(data);
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
return messages.join(' ') || String(error);
|
|
67
|
-
} catch {
|
|
68
|
-
return String(error);
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
|
|
72
61
|
/**
|
|
73
62
|
* Names a composed field must not take, because the form and next-auth already
|
|
74
63
|
* mean something by them. A local copy of the register form's set, kept local
|
|
75
64
|
* for the same reason `readAuthError` is: making it an export would change a
|
|
76
65
|
* shipped module's public surface.
|
|
77
66
|
*/
|
|
67
|
+
/**
|
|
68
|
+
* What a form does for a shopper who is already signed in. Named here rather
|
|
69
|
+
* than in `auth-destination.ts` because it is a rendering decision, not a
|
|
70
|
+
* destination one — the register form and the OTP form read the same list.
|
|
71
|
+
*/
|
|
72
|
+
export const WHEN_SIGNED_IN_MODES = ['form', 'message', 'redirect'] as const;
|
|
73
|
+
|
|
74
|
+
export type WhenSignedIn = (typeof WHEN_SIGNED_IN_MODES)[number];
|
|
75
|
+
|
|
78
76
|
const RESERVED_SUBMIT_KEYS = new Set([
|
|
79
77
|
'formType',
|
|
80
78
|
'locale',
|
|
@@ -155,10 +153,56 @@ const AuthFormBlock = ({
|
|
|
155
153
|
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
156
154
|
);
|
|
157
155
|
|
|
156
|
+
/**
|
|
157
|
+
* The modal this form is inside, if any. Read before the destination is
|
|
158
|
+
* resolved, because it decides that destination's default: a sign-in inside
|
|
159
|
+
* a drawer is a detour from the page the shopper is on, so `stay` is right
|
|
160
|
+
* there and `fixed` — the historical push — is right on a page.
|
|
161
|
+
*/
|
|
162
|
+
const hostModalId = useModalHostId();
|
|
163
|
+
/**
|
|
164
|
+
* Whether finishing closes the modal this form is inside. On here because a
|
|
165
|
+
* sign-in leaves nothing to read; the forms whose success state IS the point
|
|
166
|
+
* ("check your inbox") default to off. See `useHostModalCloser`.
|
|
167
|
+
*/
|
|
168
|
+
const closeHostModalOnSuccess = readToggle(
|
|
169
|
+
block.properties?.closeHostModalOnSuccess,
|
|
170
|
+
currentBreakpoint,
|
|
171
|
+
true
|
|
172
|
+
);
|
|
173
|
+
const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
|
|
174
|
+
|
|
158
175
|
const rawMode = prop('mode', 'login');
|
|
159
176
|
const mode = rawMode === 'register' ? 'register' : 'login';
|
|
160
177
|
const providerId = prop('providerId', 'default');
|
|
161
178
|
const callbackUrl = prop('callbackUrl', '/');
|
|
179
|
+
/**
|
|
180
|
+
* Where a finished sign-in leaves the shopper. `fixed` is the historical
|
|
181
|
+
* answer and stays the default, so no published composition moves; a drawer
|
|
182
|
+
* wants `stay` and a login PAGE wants `return`. See `auth/auth-destination`.
|
|
183
|
+
*/
|
|
184
|
+
const afterSignIn = resolveOption<AuthAfterSignIn>(
|
|
185
|
+
block.properties?.afterSignIn,
|
|
186
|
+
currentBreakpoint,
|
|
187
|
+
'fixed',
|
|
188
|
+
AUTH_AFTER_SIGN_IN_MODES as unknown as AuthAfterSignIn[]
|
|
189
|
+
);
|
|
190
|
+
/**
|
|
191
|
+
* What a shopper who is ALREADY signed in sees here.
|
|
192
|
+
*
|
|
193
|
+
* `form` is what shipped, and it is a dead end: a back button after signing
|
|
194
|
+
* in, a bookmarked /auth or a second tab shows a member a login form, and
|
|
195
|
+
* re-submitting it re-authenticates a live session. `message` says so;
|
|
196
|
+
* `redirect` takes them where they were going.
|
|
197
|
+
*/
|
|
198
|
+
const whenSignedIn = resolveOption<WhenSignedIn>(
|
|
199
|
+
block.properties?.whenSignedIn,
|
|
200
|
+
currentBreakpoint,
|
|
201
|
+
'form',
|
|
202
|
+
WHEN_SIGNED_IN_MODES as unknown as WhenSignedIn[]
|
|
203
|
+
);
|
|
204
|
+
const signedInLabel = prop('signedInLabel', 'You are already signed in.');
|
|
205
|
+
const signedInHref = prop('signedInHref', '/account');
|
|
162
206
|
const submitLabel = prop(
|
|
163
207
|
'submitLabel',
|
|
164
208
|
mode === 'register' ? 'Create account' : 'Log in'
|
|
@@ -167,6 +211,31 @@ const AuthFormBlock = ({
|
|
|
167
211
|
// the request is in flight, which is what every published block does today.
|
|
168
212
|
const busyLabel = prop('busyLabel', '');
|
|
169
213
|
const errorLabel = prop('errorLabel', 'Authentication failed.');
|
|
214
|
+
/**
|
|
215
|
+
* What the shopper is told when commerce escalates to a captcha after too
|
|
216
|
+
* many attempts. Nothing in this theme can ANSWER the challenge yet, so the
|
|
217
|
+
* least this can do is name it — the alternative, which shipped, was the
|
|
218
|
+
* literal `[{"type":"captcha"}]` in the error slot.
|
|
219
|
+
*/
|
|
220
|
+
const captchaLabel = prop(
|
|
221
|
+
'captchaLabel',
|
|
222
|
+
'Please complete the security check and try again.'
|
|
223
|
+
);
|
|
224
|
+
/**
|
|
225
|
+
* Whether this form offers the security check commerce escalates to.
|
|
226
|
+
*
|
|
227
|
+
* Off by default, and not because the challenge is optional: `useCaptcha`
|
|
228
|
+
* fetches the site key on mount, and a storefront whose commerce never
|
|
229
|
+
* escalates should not pay a request per auth form for a widget it will
|
|
230
|
+
* never show. A brand that IS abuse-protected turns it on once, places a
|
|
231
|
+
* Captcha block where the challenge should appear (or lets the form draw it
|
|
232
|
+
* above the submit), and composed login stops dead-ending.
|
|
233
|
+
*/
|
|
234
|
+
const showCaptcha = readToggle(
|
|
235
|
+
block.properties?.showCaptcha,
|
|
236
|
+
currentBreakpoint,
|
|
237
|
+
false
|
|
238
|
+
);
|
|
170
239
|
const showLabels = readToggle(
|
|
171
240
|
block.properties?.showLabels,
|
|
172
241
|
currentBreakpoint,
|
|
@@ -188,9 +257,16 @@ const AuthFormBlock = ({
|
|
|
188
257
|
* Empty keeps the URL, so every published form behaves as before.
|
|
189
258
|
*/
|
|
190
259
|
const forgotPasswordStepTarget = prop('forgotPasswordStepTarget', '').trim();
|
|
260
|
+
/**
|
|
261
|
+
* `register`, not `registration`: the credentials provider resolves the
|
|
262
|
+
* endpoint as `user[formType]` and the core url map has only `register`, so
|
|
263
|
+
* the old default POSTed the sign-up to `${commerceUrl}undefined`. A brand
|
|
264
|
+
* whose own provider names the segment differently still says so here — that
|
|
265
|
+
* is what the property is for.
|
|
266
|
+
*/
|
|
191
267
|
const formType =
|
|
192
268
|
mode === 'register'
|
|
193
|
-
? prop('registerFormType', '
|
|
269
|
+
? prop('registerFormType', 'register')
|
|
194
270
|
: prop('loginFormType', 'login');
|
|
195
271
|
// Off by default: a form on a plain auth page must not start closing modals
|
|
196
272
|
// it does not live in. Compositions inside a drawer turn it on so a login
|
|
@@ -222,6 +298,25 @@ const AuthFormBlock = ({
|
|
|
222
298
|
);
|
|
223
299
|
|
|
224
300
|
const router = useRouter();
|
|
301
|
+
const search = useSearchParams();
|
|
302
|
+
const pathname = usePathname();
|
|
303
|
+
const { status } = useSession();
|
|
304
|
+
/**
|
|
305
|
+
* `loading` counts as a guest on purpose. The opposite — withholding the
|
|
306
|
+
* form until the session is known — shows every shopper an empty panel for
|
|
307
|
+
* the first client render; this way the worst case is a member seeing the
|
|
308
|
+
* form for a beat, which is the state they are being moved out of anyway.
|
|
309
|
+
*/
|
|
310
|
+
const isSignedIn = status === 'authenticated' && !isDesigner;
|
|
311
|
+
const destination = resolveAuthDestination({
|
|
312
|
+
mode: afterSignIn,
|
|
313
|
+
authored: callbackUrl,
|
|
314
|
+
search,
|
|
315
|
+
// A sign-in inside a drawer is a detour: a destination that names nowhere
|
|
316
|
+
// in particular means there is nowhere to go. An authored one still wins.
|
|
317
|
+
inModal: Boolean(hostModalId),
|
|
318
|
+
pathname
|
|
319
|
+
});
|
|
225
320
|
const { locale, defaultLocaleValue } = useLocalization();
|
|
226
321
|
const contextFlowId = useContext(AuthFlowIdContext);
|
|
227
322
|
const forgotPasswordStep = resolveAuthStepTarget(
|
|
@@ -239,6 +334,16 @@ const AuthFormBlock = ({
|
|
|
239
334
|
// Per FIELD, as opposed to `error` above, which is what the auth backend
|
|
240
335
|
// answered about the attempt as a whole.
|
|
241
336
|
const [fieldErrors, setFieldErrors] = useState<AuthFieldErrors>({});
|
|
337
|
+
/**
|
|
338
|
+
* The security check's api, kept in a ref rather than in state: the form
|
|
339
|
+
* reads `validated` and `validate` at SUBMIT time, and re-rendering the
|
|
340
|
+
* whole form every time the widget changes its mind would be a re-render for
|
|
341
|
+
* nothing. `AuthCaptchaHost` pushes it here.
|
|
342
|
+
*/
|
|
343
|
+
const captchaRef = useRef<AuthCaptchaApi | null>(null);
|
|
344
|
+
const holdCaptchaApi = React.useCallback((api: AuthCaptchaApi) => {
|
|
345
|
+
captchaRef.current = api;
|
|
346
|
+
}, []);
|
|
242
347
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
243
348
|
|
|
244
349
|
const setField = (key: keyof typeof values) => (
|
|
@@ -280,6 +385,31 @@ const AuthFormBlock = ({
|
|
|
280
385
|
return () => clearFormSubmitState(formHtmlId);
|
|
281
386
|
}, [formHtmlId, isSubmitting]);
|
|
282
387
|
|
|
388
|
+
/**
|
|
389
|
+
* A shopper who is already signed in, taken where they were going.
|
|
390
|
+
*
|
|
391
|
+
* `replace`, not `push`: the login screen is not a place to come back to
|
|
392
|
+
* with the browser's Back button — that is precisely the loop that shows a
|
|
393
|
+
* member a login form again.
|
|
394
|
+
*/
|
|
395
|
+
React.useEffect(() => {
|
|
396
|
+
if (!isSignedIn || whenSignedIn !== 'redirect') return;
|
|
397
|
+
|
|
398
|
+
router.replace(resolveSignedInDestination(signedInHref, search));
|
|
399
|
+
}, [isSignedIn, whenSignedIn, signedInHref, router, search]);
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The form's contents, wrapped so a Captcha block (or the form's own slot)
|
|
403
|
+
* can reach the challenge — and left exactly as they were when the form does
|
|
404
|
+
* not offer one, which is every published composition.
|
|
405
|
+
*/
|
|
406
|
+
const withCaptcha = (contents: React.ReactNode) =>
|
|
407
|
+
showCaptcha ? (
|
|
408
|
+
<AuthCaptchaHost onApi={holdCaptchaApi}>{contents}</AuthCaptchaHost>
|
|
409
|
+
) : (
|
|
410
|
+
contents
|
|
411
|
+
);
|
|
412
|
+
|
|
283
413
|
const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
|
|
284
414
|
const target = event.target as { name?: string } | null;
|
|
285
415
|
|
|
@@ -294,6 +424,18 @@ const AuthFormBlock = ({
|
|
|
294
424
|
|
|
295
425
|
if (isDesigner || isSubmitting) return;
|
|
296
426
|
|
|
427
|
+
/**
|
|
428
|
+
* A live session must not be re-authenticated. Enforced whatever the
|
|
429
|
+
* `whenSignedIn` policy is: a composition left on `form` still has a form
|
|
430
|
+
* on screen, and submitting it would swap one valid session for another
|
|
431
|
+
* for no reason — with the shopper's password on the wire again.
|
|
432
|
+
*/
|
|
433
|
+
if (isSignedIn) {
|
|
434
|
+
setError(signedInLabel);
|
|
435
|
+
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
297
439
|
// Before anything is sent: the form carries `noValidate`, so nothing else
|
|
298
440
|
// is going to stop an empty submit, and nothing else would have told the
|
|
299
441
|
// shopper WHICH field is the problem.
|
|
@@ -347,12 +489,53 @@ const AuthFormBlock = ({
|
|
|
347
489
|
setIsSubmitting(true);
|
|
348
490
|
|
|
349
491
|
try {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
492
|
+
/**
|
|
493
|
+
* One attempt. Called twice at most: a captcha escalation is answered
|
|
494
|
+
* and the same body is sent again, which is what the brands' own views
|
|
495
|
+
* do — the shopper solved the challenge, so asking them to press the
|
|
496
|
+
* button a second time would be the storefront's failure, not theirs.
|
|
497
|
+
*/
|
|
498
|
+
const attempt = (solvedNow?: boolean) =>
|
|
499
|
+
signIn(providerId, {
|
|
500
|
+
// Sent only while the form offers a challenge, so a storefront
|
|
501
|
+
// without one keeps posting exactly the body it posts today.
|
|
502
|
+
...(showCaptcha
|
|
503
|
+
? {
|
|
504
|
+
captchaValidated:
|
|
505
|
+
solvedNow ?? captchaRef.current?.validated ?? false
|
|
506
|
+
}
|
|
507
|
+
: null),
|
|
508
|
+
// After the payload: these decide whether next-auth answers at all.
|
|
509
|
+
...payload,
|
|
510
|
+
redirect: false,
|
|
511
|
+
callbackUrl
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
let response = await attempt();
|
|
515
|
+
let reading = splitByKnownFields(
|
|
516
|
+
readAuthError(signInErrorDetail(response), { captcha: captchaLabel }),
|
|
517
|
+
form
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
if (reading.hasCaptchaChallenge && showCaptcha) {
|
|
521
|
+
// `validate` reveals the widget and resolves false the first time;
|
|
522
|
+
// once the shopper has solved it the hook validates the answer and
|
|
523
|
+
// resolves true, and only then is there any point in retrying.
|
|
524
|
+
const solved = await captchaRef.current?.validate();
|
|
525
|
+
|
|
526
|
+
if (solved) {
|
|
527
|
+
// `true`, not the ref: what `validate` just resolved IS the answer, and
|
|
528
|
+
// the ref only catches up on the host's next render — which may not
|
|
529
|
+
// have happened by the time this retry goes out.
|
|
530
|
+
response = await attempt(true);
|
|
531
|
+
reading = splitByKnownFields(
|
|
532
|
+
readAuthError(signInErrorDetail(response), {
|
|
533
|
+
captcha: captchaLabel
|
|
534
|
+
}),
|
|
535
|
+
form
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
356
539
|
|
|
357
540
|
if (!response || response.error) {
|
|
358
541
|
const errorDetail = signInErrorDetail(response);
|
|
@@ -368,7 +551,18 @@ const AuthFormBlock = ({
|
|
|
368
551
|
});
|
|
369
552
|
}
|
|
370
553
|
|
|
371
|
-
|
|
554
|
+
// Field-level refusals ("no account with this e-mail") go under the
|
|
555
|
+
// field, the way client-side validation already does; only what
|
|
556
|
+
// belongs to the form as a whole stays above it.
|
|
557
|
+
//
|
|
558
|
+
// `reading` is the same refusal `main` read with `readAuthError`, only
|
|
559
|
+
// already split by field — `splitByKnownFields` wraps that very call
|
|
560
|
+
// above, and `authFormMessage` keeps `errorLabel` as its fallback. So
|
|
561
|
+
// the message a shopper sees is unchanged for a form-level refusal and
|
|
562
|
+
// moves under the field for one that names a field.
|
|
563
|
+
setFieldErrors((current) => ({ ...current, ...reading.fieldMessages }));
|
|
564
|
+
focusFirstAuthFieldError(form, reading.fieldMessages);
|
|
565
|
+
setError(authFormMessage(reading, errorLabel));
|
|
372
566
|
|
|
373
567
|
return;
|
|
374
568
|
}
|
|
@@ -382,9 +576,38 @@ const AuthFormBlock = ({
|
|
|
382
576
|
emitCommerceEvent('login', { method: providerId });
|
|
383
577
|
}
|
|
384
578
|
|
|
579
|
+
/**
|
|
580
|
+
* The surface this form finished inside.
|
|
581
|
+
*
|
|
582
|
+
* Closing the modal it is IN is the default because it is never a
|
|
583
|
+
* surprise: a shopper who signed in inside the drawer is done with the
|
|
584
|
+
* drawer. `closeModalsOnSuccess` keeps its own, wider meaning — close
|
|
585
|
+
* EVERY open modal — for a composition that authored it, which is what a
|
|
586
|
+
* form nested two overlays deep needs.
|
|
587
|
+
*
|
|
588
|
+
* The live GANT drawer is why the default had to change: its OTP form has
|
|
589
|
+
* the toggle on and its login and register forms do not, so signing in
|
|
590
|
+
* with an e-mail left the drawer covering the page.
|
|
591
|
+
*/
|
|
592
|
+
/**
|
|
593
|
+
* Both, not either: a composition that ticked the legacy toggle asked
|
|
594
|
+
* for "every open modal", and the wide close is a superset — so the
|
|
595
|
+
* narrow one runs unconditionally and simply finds nothing left to do.
|
|
596
|
+
* Sequencing them the other way round (an `else`) would have left every
|
|
597
|
+
* already-published drawer on the wide branch forever, which is the one
|
|
598
|
+
* behaviour this family exists to stop.
|
|
599
|
+
*/
|
|
385
600
|
if (closeModalsOnSuccess) closeAllModals();
|
|
601
|
+
closeHostModal();
|
|
602
|
+
|
|
603
|
+
// The new session has to reach the page either way; in `stay` mode this
|
|
604
|
+
// refresh IS the whole navigation.
|
|
386
605
|
router.refresh();
|
|
387
|
-
|
|
606
|
+
|
|
607
|
+
// Our own resolved path rather than next-auth's echo of it: the response
|
|
608
|
+
// url is whatever we handed in, and this one has been through the
|
|
609
|
+
// same-origin check. `null` is `stay` — there is nowhere to go.
|
|
610
|
+
if (destination) router.push(destination);
|
|
388
611
|
} catch {
|
|
389
612
|
setError(errorLabel);
|
|
390
613
|
} finally {
|
|
@@ -517,9 +740,33 @@ const AuthFormBlock = ({
|
|
|
517
740
|
* be embedding the storefront — and every copy was a place a new message
|
|
518
741
|
* field had to be remembered.
|
|
519
742
|
*/
|
|
743
|
+
/**
|
|
744
|
+
* What the composition's Form State containers read.
|
|
745
|
+
*
|
|
746
|
+
* A sign-in has no success SCREEN — it is in, and the page moves on — so the
|
|
747
|
+
* screen here is always the form itself; what a composition can scope to is
|
|
748
|
+
* the submission running and the refusal, which is what a "signing you in…"
|
|
749
|
+
* panel and a designed error box need.
|
|
750
|
+
*/
|
|
751
|
+
const formStateValue: AuthFormStateValue = {
|
|
752
|
+
screen: 'form',
|
|
753
|
+
busy: isSubmitting,
|
|
754
|
+
hasError: Boolean(error)
|
|
755
|
+
};
|
|
756
|
+
|
|
520
757
|
const renderChildren = () =>
|
|
521
758
|
renderComposedChildren({
|
|
522
|
-
|
|
759
|
+
// A child that is not a state container is shared chrome and always
|
|
760
|
+
// shows; see `auth/form-state-context.tsx`.
|
|
761
|
+
blocks: childBlocks.filter((childBlock) =>
|
|
762
|
+
showsInFormState({
|
|
763
|
+
block: childBlock,
|
|
764
|
+
state: formStateValue,
|
|
765
|
+
isDesigner,
|
|
766
|
+
selectedBlockId,
|
|
767
|
+
currentBreakpoint
|
|
768
|
+
})
|
|
769
|
+
),
|
|
523
770
|
placeholderId,
|
|
524
771
|
sectionId,
|
|
525
772
|
isDesigner,
|
|
@@ -527,6 +774,25 @@ const AuthFormBlock = ({
|
|
|
527
774
|
currentBreakpoint
|
|
528
775
|
});
|
|
529
776
|
|
|
777
|
+
/**
|
|
778
|
+
* Told, rather than shown a form they cannot use. The `redirect` policy has
|
|
779
|
+
* this too, for the beat before the navigation lands — and for a shopper
|
|
780
|
+
* whose browser refuses it.
|
|
781
|
+
*/
|
|
782
|
+
if (isSignedIn && whenSignedIn !== 'form') {
|
|
783
|
+
return (
|
|
784
|
+
<div
|
|
785
|
+
data-auth-form=""
|
|
786
|
+
className="w-full"
|
|
787
|
+
data-auth-form-state="signed-in"
|
|
788
|
+
{...partAttrs('signed-in')}
|
|
789
|
+
role="status"
|
|
790
|
+
>
|
|
791
|
+
{signedInLabel}
|
|
792
|
+
</div>
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
|
|
530
796
|
if (isComposed) {
|
|
531
797
|
/**
|
|
532
798
|
* The composed form: children carry the fields (native inputs named by
|
|
@@ -535,9 +801,14 @@ const AuthFormBlock = ({
|
|
|
535
801
|
* the designer wires their own CTA.
|
|
536
802
|
*/
|
|
537
803
|
const errorIsAuthored = containsAuthErrorBlock(childBlocks);
|
|
804
|
+
// The form draws the widget only when the composition did not place a
|
|
805
|
+
// Captcha block — same relationship the built-in error line has with a
|
|
806
|
+
// Form Error block.
|
|
807
|
+
const captchaIsAuthored = containsCaptchaBlock(childBlocks);
|
|
538
808
|
|
|
539
809
|
return (
|
|
540
810
|
<AuthFormErrorProvider value={error}>
|
|
811
|
+
<AuthFormStateProvider value={formStateValue}>
|
|
541
812
|
<AuthFieldErrorsProvider value={fieldErrors}>
|
|
542
813
|
<form
|
|
543
814
|
id={formHtmlId || undefined}
|
|
@@ -559,7 +830,13 @@ const AuthFormBlock = ({
|
|
|
559
830
|
gap: 'var(--auth-form-gap, 12px)'
|
|
560
831
|
}}
|
|
561
832
|
>
|
|
562
|
-
{
|
|
833
|
+
{withCaptcha(
|
|
834
|
+
<>
|
|
835
|
+
{renderChildren()}
|
|
836
|
+
|
|
837
|
+
{!captchaIsAuthored && <AuthCaptchaSlot />}
|
|
838
|
+
</>
|
|
839
|
+
)}
|
|
563
840
|
|
|
564
841
|
{forgotPasswordControl}
|
|
565
842
|
|
|
@@ -595,6 +872,7 @@ const AuthFormBlock = ({
|
|
|
595
872
|
)}
|
|
596
873
|
</form>
|
|
597
874
|
</AuthFieldErrorsProvider>
|
|
875
|
+
</AuthFormStateProvider>
|
|
598
876
|
</AuthFormErrorProvider>
|
|
599
877
|
);
|
|
600
878
|
}
|
|
@@ -655,6 +933,11 @@ const AuthFormBlock = ({
|
|
|
655
933
|
|
|
656
934
|
{forgotPasswordControl}
|
|
657
935
|
|
|
936
|
+
{/* A childless form has nowhere for a Captcha block to be, so it draws
|
|
937
|
+
the challenge itself — above its own submit, where the brands' own
|
|
938
|
+
views put it. */}
|
|
939
|
+
{withCaptcha(<AuthCaptchaSlot />)}
|
|
940
|
+
|
|
658
941
|
{error && (
|
|
659
942
|
<div data-auth-form-error="" {...partAttrs('error')} role="alert">
|
|
660
943
|
{error}
|
|
@@ -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);
|