@akinon/pz-theme 2.0.95-beta.1 → 2.0.95-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -15
- package/package.json +4 -4
- package/src/__tests__/auth-commerce-events.test.tsx +1 -5
- package/src/__tests__/auth-form-error-block.test.tsx +0 -2
- package/src/__tests__/auth-form-validation.test.tsx +0 -2
- package/src/__tests__/auth-slot-coverage.test.ts +1 -9
- package/src/__tests__/auth-step-title.test.tsx +0 -2
- package/src/__tests__/detached-submit-state.test.tsx +2 -80
- package/src/__tests__/forgot-password-composed.test.tsx +0 -2
- package/src/__tests__/otp-form-composed.test.tsx +0 -2
- package/src/__tests__/password-reset-step.test.tsx +0 -2
- package/src/__tests__/register-form-composed.test.tsx +4 -253
- package/src/__tests__/registry-parity.test.ts +0 -12
- package/src/auth/auth-field-errors.tsx +0 -31
- package/src/blocks/account-button-block.tsx +0 -25
- package/src/blocks/add-to-cart-block.tsx +0 -15
- package/src/blocks/auth-field-block.tsx +3 -13
- package/src/blocks/auth-flow-block.tsx +3 -45
- package/src/blocks/auth-form-block.tsx +54 -337
- package/src/blocks/block-renderer-registry.tsx +0 -8
- package/src/blocks/button-block.tsx +13 -43
- package/src/blocks/change-password-form-block.tsx +0 -15
- package/src/blocks/forgot-password-block.tsx +9 -132
- package/src/blocks/newsletter-form-block.tsx +0 -16
- package/src/blocks/otp-form-block.tsx +67 -161
- package/src/blocks/profile-form-block.tsx +0 -13
- package/src/blocks/register-form-block.tsx +110 -362
- package/src/chrome/__tests__/theme-config-source.test.ts +1 -2
- package/src/chrome/placeholder-slugs.ts +0 -3
- package/src/chrome/route-slug.ts +0 -11
- package/src/index.ts +0 -10
- package/src/overlays/__tests__/modal-registry.test.ts +1 -5
- package/src/overlays/modal-chrome-css.ts +1 -8
- package/src/overlays/modal-preview-host.tsx +2 -7
- package/src/overlays/modal-registry.ts +1 -38
- package/src/overlays/overlay-surface.tsx +0 -23
- package/src/overlays/overlay-tokens.ts +0 -12
- package/src/overlays/theme-modal-mount.tsx +6 -85
- package/src/utils/visibility-rules.ts +0 -114
- package/src/__tests__/auth-captcha.test.tsx +0 -261
- package/src/__tests__/auth-destination.test.tsx +0 -348
- package/src/__tests__/auth-error-reading.test.ts +0 -232
- package/src/__tests__/auth-flow-scope.test.tsx +0 -162
- package/src/__tests__/auth-form-success-block.test.tsx +0 -429
- package/src/__tests__/block-action-audience.test.ts +0 -154
- package/src/__tests__/form-state-composition.test.tsx +0 -484
- package/src/__tests__/modal-login-close.test.tsx +0 -366
- package/src/__tests__/reset-password-composed.test.tsx +0 -385
- package/src/auth/auth-captcha.tsx +0 -125
- package/src/auth/auth-destination.ts +0 -168
- package/src/auth/form-state-context.tsx +0 -170
- package/src/auth/form-success-context.tsx +0 -56
- package/src/auth/read-auth-error.ts +0 -332
- package/src/blocks/auth-success-block.tsx +0 -85
- package/src/blocks/captcha-block.tsx +0 -61
- package/src/blocks/form-state-block.tsx +0 -84
- package/src/blocks/reset-password-block.tsx +0 -717
- package/src/overlays/__tests__/modal-loading.test.tsx +0 -235
- package/src/overlays/modal-host-context.tsx +0 -66
- package/src/overlays/overlay-loading.tsx +0 -144
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useContext,
|
|
3
|
+
import React, { useContext, useState } from 'react';
|
|
4
4
|
import NextLink from 'next/link';
|
|
5
|
-
import {
|
|
6
|
-
import { signIn
|
|
5
|
+
import { useRouter } from 'next/navigation';
|
|
6
|
+
import { signIn } from 'next-auth/react';
|
|
7
7
|
import { useLocalization } from '@akinon/next/hooks';
|
|
8
|
-
import { getResponsiveValue, partAttrs
|
|
8
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
9
9
|
import {
|
|
10
10
|
AuthFieldErrors,
|
|
11
11
|
AuthFieldErrorsProvider,
|
|
12
12
|
clearAuthFieldError,
|
|
13
|
-
collectAuthFieldErrors
|
|
14
|
-
focusFirstAuthFieldError
|
|
13
|
+
collectAuthFieldErrors
|
|
15
14
|
} from '../auth/auth-field-errors';
|
|
16
15
|
import {
|
|
17
16
|
clearFormSubmitState,
|
|
@@ -21,58 +20,61 @@ import {
|
|
|
21
20
|
AuthFormErrorProvider,
|
|
22
21
|
containsAuthErrorBlock
|
|
23
22
|
} from '../auth/form-error-context';
|
|
24
|
-
import {
|
|
25
|
-
AuthFormStateProvider,
|
|
26
|
-
AuthFormStateValue,
|
|
27
|
-
showsInFormState
|
|
28
|
-
} from '../auth/form-state-context';
|
|
29
23
|
import { emitCommerceEvent } from '../utils/analytics';
|
|
30
24
|
import { readToggle } from './product-block-utils';
|
|
31
25
|
import { closeAllModals } from '../overlays/overlay-bus';
|
|
32
|
-
import {
|
|
33
|
-
useHostModalCloser,
|
|
34
|
-
useModalHostId
|
|
35
|
-
} from '../overlays/modal-host-context';
|
|
36
26
|
import {
|
|
37
27
|
AuthFlowIdContext,
|
|
38
28
|
resolveAuthStepTarget,
|
|
39
29
|
setAuthStep
|
|
40
30
|
} from '../auth/auth-flow-bus';
|
|
41
31
|
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';
|
|
59
32
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
60
33
|
|
|
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
|
+
|
|
61
72
|
/**
|
|
62
73
|
* Names a composed field must not take, because the form and next-auth already
|
|
63
74
|
* mean something by them. A local copy of the register form's set, kept local
|
|
64
75
|
* for the same reason `readAuthError` is: making it an export would change a
|
|
65
76
|
* shipped module's public surface.
|
|
66
77
|
*/
|
|
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
|
-
|
|
76
78
|
const RESERVED_SUBMIT_KEYS = new Set([
|
|
77
79
|
'formType',
|
|
78
80
|
'locale',
|
|
@@ -153,56 +155,10 @@ const AuthFormBlock = ({
|
|
|
153
155
|
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
154
156
|
);
|
|
155
157
|
|
|
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
|
-
|
|
175
158
|
const rawMode = prop('mode', 'login');
|
|
176
159
|
const mode = rawMode === 'register' ? 'register' : 'login';
|
|
177
160
|
const providerId = prop('providerId', 'default');
|
|
178
161
|
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');
|
|
206
162
|
const submitLabel = prop(
|
|
207
163
|
'submitLabel',
|
|
208
164
|
mode === 'register' ? 'Create account' : 'Log in'
|
|
@@ -211,31 +167,6 @@ const AuthFormBlock = ({
|
|
|
211
167
|
// the request is in flight, which is what every published block does today.
|
|
212
168
|
const busyLabel = prop('busyLabel', '');
|
|
213
169
|
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
|
-
);
|
|
239
170
|
const showLabels = readToggle(
|
|
240
171
|
block.properties?.showLabels,
|
|
241
172
|
currentBreakpoint,
|
|
@@ -257,16 +188,9 @@ const AuthFormBlock = ({
|
|
|
257
188
|
* Empty keeps the URL, so every published form behaves as before.
|
|
258
189
|
*/
|
|
259
190
|
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
|
-
*/
|
|
267
191
|
const formType =
|
|
268
192
|
mode === 'register'
|
|
269
|
-
? prop('registerFormType', '
|
|
193
|
+
? prop('registerFormType', 'registration')
|
|
270
194
|
: prop('loginFormType', 'login');
|
|
271
195
|
// Off by default: a form on a plain auth page must not start closing modals
|
|
272
196
|
// it does not live in. Compositions inside a drawer turn it on so a login
|
|
@@ -298,25 +222,6 @@ const AuthFormBlock = ({
|
|
|
298
222
|
);
|
|
299
223
|
|
|
300
224
|
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
|
-
});
|
|
320
225
|
const { locale, defaultLocaleValue } = useLocalization();
|
|
321
226
|
const contextFlowId = useContext(AuthFlowIdContext);
|
|
322
227
|
const forgotPasswordStep = resolveAuthStepTarget(
|
|
@@ -334,16 +239,6 @@ const AuthFormBlock = ({
|
|
|
334
239
|
// Per FIELD, as opposed to `error` above, which is what the auth backend
|
|
335
240
|
// answered about the attempt as a whole.
|
|
336
241
|
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
|
-
}, []);
|
|
347
242
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
348
243
|
|
|
349
244
|
const setField = (key: keyof typeof values) => (
|
|
@@ -385,31 +280,6 @@ const AuthFormBlock = ({
|
|
|
385
280
|
return () => clearFormSubmitState(formHtmlId);
|
|
386
281
|
}, [formHtmlId, isSubmitting]);
|
|
387
282
|
|
|
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
|
-
|
|
413
283
|
const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
|
|
414
284
|
const target = event.target as { name?: string } | null;
|
|
415
285
|
|
|
@@ -424,18 +294,6 @@ const AuthFormBlock = ({
|
|
|
424
294
|
|
|
425
295
|
if (isDesigner || isSubmitting) return;
|
|
426
296
|
|
|
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
|
-
|
|
439
297
|
// Before anything is sent: the form carries `noValidate`, so nothing else
|
|
440
298
|
// is going to stop an empty submit, and nothing else would have told the
|
|
441
299
|
// shopper WHICH field is the problem.
|
|
@@ -489,53 +347,12 @@ const AuthFormBlock = ({
|
|
|
489
347
|
setIsSubmitting(true);
|
|
490
348
|
|
|
491
349
|
try {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
}
|
|
350
|
+
const response = await signIn(providerId, {
|
|
351
|
+
// After the payload: these decide whether next-auth answers at all.
|
|
352
|
+
...payload,
|
|
353
|
+
redirect: false,
|
|
354
|
+
callbackUrl
|
|
355
|
+
});
|
|
539
356
|
|
|
540
357
|
if (!response || response.error) {
|
|
541
358
|
const errorDetail = signInErrorDetail(response);
|
|
@@ -551,18 +368,7 @@ const AuthFormBlock = ({
|
|
|
551
368
|
});
|
|
552
369
|
}
|
|
553
370
|
|
|
554
|
-
|
|
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));
|
|
371
|
+
setError(readAuthError(errorDetail) || errorLabel);
|
|
566
372
|
|
|
567
373
|
return;
|
|
568
374
|
}
|
|
@@ -576,38 +382,9 @@ const AuthFormBlock = ({
|
|
|
576
382
|
emitCommerceEvent('login', { method: providerId });
|
|
577
383
|
}
|
|
578
384
|
|
|
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
|
-
*/
|
|
600
385
|
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.
|
|
605
386
|
router.refresh();
|
|
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);
|
|
387
|
+
router.push(callbackUrl);
|
|
611
388
|
} catch {
|
|
612
389
|
setError(errorLabel);
|
|
613
390
|
} finally {
|
|
@@ -740,33 +517,9 @@ const AuthFormBlock = ({
|
|
|
740
517
|
* be embedding the storefront — and every copy was a place a new message
|
|
741
518
|
* field had to be remembered.
|
|
742
519
|
*/
|
|
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
|
-
|
|
757
520
|
const renderChildren = () =>
|
|
758
521
|
renderComposedChildren({
|
|
759
|
-
|
|
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
|
-
),
|
|
522
|
+
blocks: childBlocks,
|
|
770
523
|
placeholderId,
|
|
771
524
|
sectionId,
|
|
772
525
|
isDesigner,
|
|
@@ -774,25 +527,6 @@ const AuthFormBlock = ({
|
|
|
774
527
|
currentBreakpoint
|
|
775
528
|
});
|
|
776
529
|
|
|
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
|
-
|
|
796
530
|
if (isComposed) {
|
|
797
531
|
/**
|
|
798
532
|
* The composed form: children carry the fields (native inputs named by
|
|
@@ -801,14 +535,9 @@ const AuthFormBlock = ({
|
|
|
801
535
|
* the designer wires their own CTA.
|
|
802
536
|
*/
|
|
803
537
|
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);
|
|
808
538
|
|
|
809
539
|
return (
|
|
810
540
|
<AuthFormErrorProvider value={error}>
|
|
811
|
-
<AuthFormStateProvider value={formStateValue}>
|
|
812
541
|
<AuthFieldErrorsProvider value={fieldErrors}>
|
|
813
542
|
<form
|
|
814
543
|
id={formHtmlId || undefined}
|
|
@@ -830,13 +559,7 @@ const AuthFormBlock = ({
|
|
|
830
559
|
gap: 'var(--auth-form-gap, 12px)'
|
|
831
560
|
}}
|
|
832
561
|
>
|
|
833
|
-
{
|
|
834
|
-
<>
|
|
835
|
-
{renderChildren()}
|
|
836
|
-
|
|
837
|
-
{!captchaIsAuthored && <AuthCaptchaSlot />}
|
|
838
|
-
</>
|
|
839
|
-
)}
|
|
562
|
+
{renderChildren()}
|
|
840
563
|
|
|
841
564
|
{forgotPasswordControl}
|
|
842
565
|
|
|
@@ -872,7 +595,6 @@ const AuthFormBlock = ({
|
|
|
872
595
|
)}
|
|
873
596
|
</form>
|
|
874
597
|
</AuthFieldErrorsProvider>
|
|
875
|
-
</AuthFormStateProvider>
|
|
876
598
|
</AuthFormErrorProvider>
|
|
877
599
|
);
|
|
878
600
|
}
|
|
@@ -933,11 +655,6 @@ const AuthFormBlock = ({
|
|
|
933
655
|
|
|
934
656
|
{forgotPasswordControl}
|
|
935
657
|
|
|
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
|
-
|
|
941
658
|
{error && (
|
|
942
659
|
<div data-auth-form-error="" {...partAttrs('error')} role="alert">
|
|
943
660
|
{error}
|
|
@@ -79,14 +79,10 @@ 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';
|
|
84
82
|
import AuthConsentBlock from './auth-consent-block';
|
|
85
83
|
import AuthFieldBlock from './auth-field-block';
|
|
86
84
|
import OtpPhaseBlock from './otp-phase-block';
|
|
87
85
|
import OtpCodeInputBlock from './otp-code-input-block';
|
|
88
|
-
import CaptchaBlock from './captcha-block';
|
|
89
|
-
import ResetPasswordBlock from './reset-password-block';
|
|
90
86
|
import StoreLocatorBlock from './store-locator-block';
|
|
91
87
|
import FaqAccordionBlock from './faq-accordion-block';
|
|
92
88
|
import ShareBlock from './share-block';
|
|
@@ -228,14 +224,10 @@ blockRendererRegistry.register('auth-flow', AuthFlowBlock);
|
|
|
228
224
|
blockRendererRegistry.register('auth-step', AuthStepBlock);
|
|
229
225
|
blockRendererRegistry.register('auth-step-title', AuthStepTitleBlock);
|
|
230
226
|
blockRendererRegistry.register('auth-error', AuthErrorBlock);
|
|
231
|
-
blockRendererRegistry.register('auth-success', AuthSuccessBlock);
|
|
232
|
-
blockRendererRegistry.register('form-state', FormStateBlock);
|
|
233
227
|
blockRendererRegistry.register('auth-consent', AuthConsentBlock);
|
|
234
228
|
blockRendererRegistry.register('auth-field', AuthFieldBlock);
|
|
235
229
|
blockRendererRegistry.register('otp-phase', OtpPhaseBlock);
|
|
236
230
|
blockRendererRegistry.register('otp-code-input', OtpCodeInputBlock);
|
|
237
|
-
blockRendererRegistry.register('captcha', CaptchaBlock);
|
|
238
|
-
blockRendererRegistry.register('reset-password', ResetPasswordBlock);
|
|
239
231
|
blockRendererRegistry.register('store-locator', StoreLocatorBlock);
|
|
240
232
|
blockRendererRegistry.register('faq-accordion', FaqAccordionBlock);
|
|
241
233
|
blockRendererRegistry.register('share', ShareBlock);
|
|
@@ -87,29 +87,8 @@ 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
|
-
|
|
101
90
|
const textValue = block.properties?.text || block.value;
|
|
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);
|
|
91
|
+
const text = getLocalizedText(textValue);
|
|
113
92
|
|
|
114
93
|
const url = block.properties?.url
|
|
115
94
|
? String(getResponsiveValue(block.properties.url, currentBreakpoint, '#'))
|
|
@@ -154,6 +133,16 @@ const ButtonBlock = ({
|
|
|
154
133
|
* live in a pinned bottom bar while the fields stay in the flow above.
|
|
155
134
|
* Forces the <button> branch: an anchor cannot submit anything.
|
|
156
135
|
*/
|
|
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);
|
|
157
146
|
|
|
158
147
|
const iconUrl = block.properties?.icon
|
|
159
148
|
? String(getResponsiveValue(block.properties.icon, currentBreakpoint, ''))
|
|
@@ -671,27 +660,8 @@ const ButtonBlock = ({
|
|
|
671
660
|
// root styles HERE instead of onto the designer wrapper, so padding
|
|
672
661
|
// and border land once, on the element the shopper actually clicks —
|
|
673
662
|
// not once on the wrapper and again inline (which doubled them).
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
}
|
|
663
|
+
{...partAttrs('root')}
|
|
664
|
+
style={finalStyles}
|
|
695
665
|
onClick={handleClick}
|
|
696
666
|
{...action.ariaProps}
|
|
697
667
|
>
|
|
@@ -3,7 +3,6 @@
|
|
|
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';
|
|
7
6
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
8
7
|
import { readToggle } from './product-block-utils';
|
|
9
8
|
|
|
@@ -64,19 +63,6 @@ const ChangePasswordFormBlock = ({
|
|
|
64
63
|
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
65
64
|
);
|
|
66
65
|
|
|
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
|
-
|
|
80
66
|
const submitLabel = text('submitLabel', 'Change password');
|
|
81
67
|
const submitAlign = text('submitAlign', 'stretch');
|
|
82
68
|
const currentPasswordLabel = text('currentPasswordLabel', 'Current password');
|
|
@@ -139,7 +125,6 @@ const ChangePasswordFormBlock = ({
|
|
|
139
125
|
await updatePassword(values).unwrap();
|
|
140
126
|
setValues(EMPTY_FORM);
|
|
141
127
|
setIsDone(true);
|
|
142
|
-
closeHostModal();
|
|
143
128
|
} catch (requestError) {
|
|
144
129
|
setError(readPasswordError(requestError, errorMessage));
|
|
145
130
|
}
|