@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,22 +1,32 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useContext, useState } from 'react';
|
|
4
|
-
import { useRouter } from 'next/navigation';
|
|
5
|
-
import { signIn } from 'next-auth/react';
|
|
3
|
+
import React, { useContext, useRef, useState } from 'react';
|
|
4
|
+
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
5
|
+
import { signIn, useSession } from 'next-auth/react';
|
|
6
6
|
import { useLocalization } from '@akinon/next/hooks';
|
|
7
|
-
import { getResponsiveValue, partAttrs } from '../utils';
|
|
7
|
+
import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
|
|
8
8
|
import { readToggle } from './product-block-utils';
|
|
9
9
|
import { closeAllModals } from '../overlays/overlay-bus';
|
|
10
|
+
import {
|
|
11
|
+
useHostModalCloser,
|
|
12
|
+
useModalHostId
|
|
13
|
+
} from '../overlays/modal-host-context';
|
|
10
14
|
import {
|
|
11
15
|
AuthFieldErrors,
|
|
12
16
|
AuthFieldErrorsProvider,
|
|
13
17
|
clearAuthFieldError,
|
|
14
|
-
collectAuthFieldErrors
|
|
18
|
+
collectAuthFieldErrors,
|
|
19
|
+
focusFirstAuthFieldError
|
|
15
20
|
} from '../auth/auth-field-errors';
|
|
16
21
|
import {
|
|
17
22
|
AuthFormErrorProvider,
|
|
18
23
|
containsAuthErrorBlock
|
|
19
24
|
} from '../auth/form-error-context';
|
|
25
|
+
import {
|
|
26
|
+
AuthFormStateProvider,
|
|
27
|
+
AuthFormStateValue,
|
|
28
|
+
showsInFormState
|
|
29
|
+
} from '../auth/form-state-context';
|
|
20
30
|
import {
|
|
21
31
|
AUTH_CONSENT_NAMES,
|
|
22
32
|
AuthConsentHostProvider
|
|
@@ -32,60 +42,29 @@ import {
|
|
|
32
42
|
setAuthStep,
|
|
33
43
|
setPendingOtp
|
|
34
44
|
} from '../auth/auth-flow-bus';
|
|
45
|
+
import {
|
|
46
|
+
authFormMessage,
|
|
47
|
+
readAuthError,
|
|
48
|
+
splitByKnownFields
|
|
49
|
+
} from '../auth/read-auth-error';
|
|
50
|
+
import {
|
|
51
|
+
AUTH_AFTER_SIGN_IN_MODES,
|
|
52
|
+
AuthAfterSignIn,
|
|
53
|
+
resolveAuthDestination,
|
|
54
|
+
resolveSignedInDestination
|
|
55
|
+
} from '../auth/auth-destination';
|
|
56
|
+
import {
|
|
57
|
+
WHEN_SIGNED_IN_MODES,
|
|
58
|
+
WhenSignedIn
|
|
59
|
+
} from './auth-form-block';
|
|
60
|
+
import {
|
|
61
|
+
AuthCaptchaApi,
|
|
62
|
+
AuthCaptchaHost,
|
|
63
|
+
AuthCaptchaSlot,
|
|
64
|
+
containsCaptchaBlock
|
|
65
|
+
} from '../auth/auth-captcha';
|
|
35
66
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
36
67
|
|
|
37
|
-
/**
|
|
38
|
-
* pz brands serialise an AuthError[] into next-auth's `error` string, but a
|
|
39
|
-
* plain string is also possible, so both shapes are handled. Kept local rather
|
|
40
|
-
* than imported from auth-form-block: that block owns its own copy and making
|
|
41
|
-
* it an export would change a shipped module's public surface.
|
|
42
|
-
*/
|
|
43
|
-
const readAuthError = (
|
|
44
|
-
error: string | null | undefined,
|
|
45
|
-
otpMessage: string
|
|
46
|
-
): string => {
|
|
47
|
-
if (!error) return '';
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
const parsed = JSON.parse(error);
|
|
51
|
-
if (!Array.isArray(parsed)) return String(error);
|
|
52
|
-
|
|
53
|
-
const messages: string[] = [];
|
|
54
|
-
|
|
55
|
-
parsed.forEach((entry) => {
|
|
56
|
-
const type = (entry as { type?: unknown })?.type;
|
|
57
|
-
const data = (entry as { data?: unknown })?.data;
|
|
58
|
-
|
|
59
|
-
// The OTP branch carries no message: superstep answers a valid
|
|
60
|
-
// registration with an `otp` error and expects the storefront to run an
|
|
61
|
-
// SMS-verification step. When no `otpStepTarget` is set the challenge
|
|
62
|
-
// lands here, so the shopper is at least told what happened instead of
|
|
63
|
-
// seeing a silent failure.
|
|
64
|
-
if (type === 'otp') {
|
|
65
|
-
messages.push(otpMessage);
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (Array.isArray(data)) {
|
|
70
|
-
data.forEach((item) => {
|
|
71
|
-
if (typeof item === 'string') {
|
|
72
|
-
messages.push(item);
|
|
73
|
-
} else if (item && typeof item === 'object') {
|
|
74
|
-
const value = (item as { value?: unknown }).value;
|
|
75
|
-
if (typeof value === 'string') messages.push(value);
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
} else if (typeof data === 'string') {
|
|
79
|
-
messages.push(data);
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
return messages.join(' ') || String(error);
|
|
84
|
-
} catch {
|
|
85
|
-
return String(error);
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
|
|
89
68
|
/**
|
|
90
69
|
* Names a composed field must not take, because the form and next-auth already
|
|
91
70
|
* mean something by them.
|
|
@@ -109,27 +88,6 @@ const RESERVED_SUBMIT_KEYS = new Set([
|
|
|
109
88
|
'json'
|
|
110
89
|
]);
|
|
111
90
|
|
|
112
|
-
/**
|
|
113
|
-
* Whether commerce answered a valid registration with an SMS-verification
|
|
114
|
-
* challenge. Detected separately from the message extraction because the
|
|
115
|
-
* challenge is a HANDOFF, not a failure: the pending payload moves to the
|
|
116
|
-
* auth bus and an `otp-form` block finishes the sign-up.
|
|
117
|
-
*/
|
|
118
|
-
const hasOtpChallenge = (error: string | null | undefined): boolean => {
|
|
119
|
-
if (!error) return false;
|
|
120
|
-
|
|
121
|
-
try {
|
|
122
|
-
const parsed = JSON.parse(error);
|
|
123
|
-
|
|
124
|
-
return (
|
|
125
|
-
Array.isArray(parsed) &&
|
|
126
|
-
parsed.some((entry) => (entry as { type?: unknown })?.type === 'otp')
|
|
127
|
-
);
|
|
128
|
-
} catch {
|
|
129
|
-
return false;
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
|
|
133
91
|
/**
|
|
134
92
|
* Where the serialised AuthError[] actually lives depends on the next-auth
|
|
135
93
|
* major. v4 puts it into `error` itself; v5 keeps `error` at the generic
|
|
@@ -147,11 +105,27 @@ const signInErrorDetail = (
|
|
|
147
105
|
|
|
148
106
|
/**
|
|
149
107
|
* The commerce registration endpoint is reached through next-auth's credentials
|
|
150
|
-
* provider, exactly like the brand's own register view:
|
|
151
|
-
* `
|
|
108
|
+
* provider, exactly like the brand's own register view: a provider id and a
|
|
109
|
+
* `formType` that selects the url segment. Both are authored properties now —
|
|
110
|
+
* see `providerId` / `formType` in the renderer — because the compiled-in
|
|
111
|
+
* `registration` is not a key the core url map has.
|
|
152
112
|
*/
|
|
153
|
-
const
|
|
154
|
-
const
|
|
113
|
+
const DEFAULT_PROVIDER_ID = 'default';
|
|
114
|
+
const DEFAULT_FORM_TYPE = 'register';
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The per-channel marketing permissions commerce records, in the order the
|
|
118
|
+
* brand's own register view sends them.
|
|
119
|
+
*
|
|
120
|
+
* Named here rather than written out at the call site because each one is a
|
|
121
|
+
* name a composition can author a tick for — see `authoredTick` in the submit.
|
|
122
|
+
*/
|
|
123
|
+
const AUTH_MARKETING_CHANNEL_FLAGS = [
|
|
124
|
+
'email_allowed',
|
|
125
|
+
'sms_allowed',
|
|
126
|
+
'call_allowed',
|
|
127
|
+
'whatsapp_allowed'
|
|
128
|
+
] as const;
|
|
155
129
|
|
|
156
130
|
/**
|
|
157
131
|
* Functional auth block: full registration form (name, surname, e-mail,
|
|
@@ -184,6 +158,25 @@ const RegisterFormBlock = ({
|
|
|
184
158
|
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
185
159
|
);
|
|
186
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The modal this form is inside, if any. Read before the destination is
|
|
163
|
+
* resolved, because it decides that destination's default: a sign-in inside
|
|
164
|
+
* a drawer is a detour from the page the shopper is on, so `stay` is right
|
|
165
|
+
* there and `fixed` — the historical push — is right on a page.
|
|
166
|
+
*/
|
|
167
|
+
const hostModalId = useModalHostId();
|
|
168
|
+
/**
|
|
169
|
+
* Whether finishing closes the modal this form is inside. On here because a
|
|
170
|
+
* sign-in leaves nothing to read; the forms whose success state IS the point
|
|
171
|
+
* ("check your inbox") default to off. See `useHostModalCloser`.
|
|
172
|
+
*/
|
|
173
|
+
const closeHostModalOnSuccess = readToggle(
|
|
174
|
+
block.properties?.closeHostModalOnSuccess,
|
|
175
|
+
currentBreakpoint,
|
|
176
|
+
true
|
|
177
|
+
);
|
|
178
|
+
const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
|
|
179
|
+
|
|
187
180
|
const submitLabel = prop('submitLabel', 'Create account');
|
|
188
181
|
// Empty on purpose: without a busy label the submit caption stays put while
|
|
189
182
|
// the request runs, which is what published blocks render today.
|
|
@@ -227,6 +220,54 @@ const RegisterFormBlock = ({
|
|
|
227
220
|
'otpLabel',
|
|
228
221
|
'A verification code was sent. Please confirm it to finish.'
|
|
229
222
|
);
|
|
223
|
+
const captchaLabel = prop(
|
|
224
|
+
'captchaLabel',
|
|
225
|
+
'Please complete the security check and try again.'
|
|
226
|
+
);
|
|
227
|
+
/**
|
|
228
|
+
* Whether this form offers the security check commerce escalates to. Off by
|
|
229
|
+
* default because `useCaptcha` fetches the site key on mount — see
|
|
230
|
+
* `auth/auth-captcha.tsx`.
|
|
231
|
+
*/
|
|
232
|
+
const showCaptcha = readToggle(
|
|
233
|
+
block.properties?.showCaptcha,
|
|
234
|
+
currentBreakpoint,
|
|
235
|
+
false
|
|
236
|
+
);
|
|
237
|
+
/**
|
|
238
|
+
* Which credentials provider and which commerce endpoint the sign-up goes
|
|
239
|
+
* to. Authored for the same reason the Auth Form authors them: the provider
|
|
240
|
+
* id and the `formType` are how a brand points a form at its own provider,
|
|
241
|
+
* and a brand whose url map names the endpoint differently had no way to say
|
|
242
|
+
* so — the values were compiled in.
|
|
243
|
+
*
|
|
244
|
+
* `register` is the default because that is the key the core provider's url
|
|
245
|
+
* map actually has (`@akinon/next/data/urls.ts`, `user.register`). The
|
|
246
|
+
* compiled-in `registration` resolved to `undefined`, so the sign-up POSTed
|
|
247
|
+
* to `${commerceUrl}undefined` and no account was ever created.
|
|
248
|
+
*/
|
|
249
|
+
const providerId = prop('providerId', DEFAULT_PROVIDER_ID);
|
|
250
|
+
const formType = prop('formType', DEFAULT_FORM_TYPE);
|
|
251
|
+
const callbackUrl = prop('callbackUrl', '/');
|
|
252
|
+
/**
|
|
253
|
+
* Where a finished sign-up leaves the shopper, and what one who is already
|
|
254
|
+
* signed in sees here. Same three modes and same default as the Auth Form —
|
|
255
|
+
* see `auth/auth-destination.ts`.
|
|
256
|
+
*/
|
|
257
|
+
const afterSignIn = resolveOption<AuthAfterSignIn>(
|
|
258
|
+
block.properties?.afterSignIn,
|
|
259
|
+
currentBreakpoint,
|
|
260
|
+
'fixed',
|
|
261
|
+
AUTH_AFTER_SIGN_IN_MODES as unknown as AuthAfterSignIn[]
|
|
262
|
+
);
|
|
263
|
+
const whenSignedIn = resolveOption<WhenSignedIn>(
|
|
264
|
+
block.properties?.whenSignedIn,
|
|
265
|
+
currentBreakpoint,
|
|
266
|
+
'form',
|
|
267
|
+
WHEN_SIGNED_IN_MODES as unknown as WhenSignedIn[]
|
|
268
|
+
);
|
|
269
|
+
const signedInLabel = prop('signedInLabel', 'You are already signed in.');
|
|
270
|
+
const signedInHref = prop('signedInHref', '/account');
|
|
230
271
|
// Where the SMS-verification challenge sends the shopper: a step key (or
|
|
231
272
|
// `flow:step`) whose step holds an `otp-form` block. Empty keeps the old
|
|
232
273
|
// behaviour — the otpLabel message — so published compositions are
|
|
@@ -252,6 +293,20 @@ const RegisterFormBlock = ({
|
|
|
252
293
|
);
|
|
253
294
|
|
|
254
295
|
const router = useRouter();
|
|
296
|
+
const search = useSearchParams();
|
|
297
|
+
const pathname = usePathname();
|
|
298
|
+
const { status } = useSession();
|
|
299
|
+
// `loading` counts as a guest — see the note in auth-form-block.
|
|
300
|
+
const isSignedIn = status === 'authenticated' && !isDesigner;
|
|
301
|
+
const destination = resolveAuthDestination({
|
|
302
|
+
mode: afterSignIn,
|
|
303
|
+
authored: callbackUrl,
|
|
304
|
+
search,
|
|
305
|
+
// A sign-in inside a drawer is a detour: a destination that names nowhere
|
|
306
|
+
// in particular means there is nowhere to go. An authored one still wins.
|
|
307
|
+
inModal: Boolean(hostModalId),
|
|
308
|
+
pathname
|
|
309
|
+
});
|
|
255
310
|
const { locale, defaultLocaleValue } = useLocalization();
|
|
256
311
|
const contextFlowId = useContext(AuthFlowIdContext);
|
|
257
312
|
|
|
@@ -279,6 +334,37 @@ const RegisterFormBlock = ({
|
|
|
279
334
|
// Per FIELD, as opposed to `error`, which is what the backend answered about
|
|
280
335
|
// the attempt as a whole.
|
|
281
336
|
const [fieldErrors, setFieldErrors] = useState<AuthFieldErrors>({});
|
|
337
|
+
/**
|
|
338
|
+
* The security check's api. A ref, not state: the form reads it at SUBMIT
|
|
339
|
+
* time, and re-rendering a whole sign-up form every time the widget changes
|
|
340
|
+
* its mind would be a re-render for nothing.
|
|
341
|
+
*/
|
|
342
|
+
const captchaRef = useRef<AuthCaptchaApi | null>(null);
|
|
343
|
+
const holdCaptchaApi = React.useCallback((api: AuthCaptchaApi) => {
|
|
344
|
+
captchaRef.current = api;
|
|
345
|
+
}, []);
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* A shopper who is already signed in, taken where they were going. `replace`
|
|
349
|
+
* so the Back button does not put them on a sign-up form again.
|
|
350
|
+
*/
|
|
351
|
+
React.useEffect(() => {
|
|
352
|
+
if (!isSignedIn || whenSignedIn !== 'redirect') return;
|
|
353
|
+
|
|
354
|
+
router.replace(resolveSignedInDestination(signedInHref, search));
|
|
355
|
+
}, [isSignedIn, whenSignedIn, signedInHref, router, search]);
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The form's contents, wrapped so a Security Check block (or the form's own
|
|
359
|
+
* slot) can reach the challenge. Left exactly as they were when the form
|
|
360
|
+
* does not offer one, which is every published composition.
|
|
361
|
+
*/
|
|
362
|
+
const withCaptcha = (contents: React.ReactNode) =>
|
|
363
|
+
showCaptcha ? (
|
|
364
|
+
<AuthCaptchaHost onApi={holdCaptchaApi}>{contents}</AuthCaptchaHost>
|
|
365
|
+
) : (
|
|
366
|
+
contents
|
|
367
|
+
);
|
|
282
368
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
283
369
|
|
|
284
370
|
const setField = (key: keyof typeof values) => (
|
|
@@ -321,6 +407,14 @@ const RegisterFormBlock = ({
|
|
|
321
407
|
// Submitting inside the designer would create a real commerce account.
|
|
322
408
|
if (isDesigner) return;
|
|
323
409
|
|
|
410
|
+
// A live session must not be handed a second account. Enforced whatever
|
|
411
|
+
// the `whenSignedIn` policy is — see auth-form-block.
|
|
412
|
+
if (isSignedIn) {
|
|
413
|
+
setError(signedInLabel);
|
|
414
|
+
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
324
418
|
// Held onto because `currentTarget` is only the form for as long as the
|
|
325
419
|
// handler is on the stack, and the consent question below is asked of the
|
|
326
420
|
// markup rather than of state.
|
|
@@ -349,7 +443,7 @@ const RegisterFormBlock = ({
|
|
|
349
443
|
}
|
|
350
444
|
|
|
351
445
|
const payload: Record<string, string | boolean> = {
|
|
352
|
-
formType
|
|
446
|
+
formType,
|
|
353
447
|
// The credentials provider looks the locale up in Settings and throws
|
|
354
448
|
// when it is missing, so an explicit value is always sent.
|
|
355
449
|
locale: locale || defaultLocaleValue || ''
|
|
@@ -440,16 +534,44 @@ const RegisterFormBlock = ({
|
|
|
440
534
|
return;
|
|
441
535
|
}
|
|
442
536
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
537
|
+
/**
|
|
538
|
+
* A flag the composition asked about ITSELF, or `undefined` when it did
|
|
539
|
+
* not ask.
|
|
540
|
+
*
|
|
541
|
+
* Only a tick counts: a Custom field named after a flag would answer
|
|
542
|
+
* `checked === false` on a text input and post a refusal the shopper never
|
|
543
|
+
* gave, so the query is narrowed to checkboxes.
|
|
544
|
+
*/
|
|
545
|
+
const authoredTick = (name: string): boolean | undefined => {
|
|
546
|
+
if (!isComposed) return undefined;
|
|
547
|
+
|
|
548
|
+
const control = formElement.querySelector(
|
|
549
|
+
`input[type="checkbox"][name="${name}"]`
|
|
550
|
+
) as HTMLInputElement | null;
|
|
551
|
+
|
|
552
|
+
return control ? control.checked : undefined;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const agreedToLegal = legalControl ? agreedToTerms : true;
|
|
556
|
+
|
|
557
|
+
payload.is_allowed = agreedToLegal;
|
|
558
|
+
payload.confirm = authoredTick('confirm') ?? agreedToLegal;
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The four marketing channels, each carrying the answer the shopper
|
|
562
|
+
* actually gave.
|
|
563
|
+
*
|
|
564
|
+
* They used to be fanned out from the single marketing tick, which turned
|
|
565
|
+
* a composition that asks per channel into a lie: a shopper who ticked
|
|
566
|
+
* e-mail and left SMS alone was recorded as refusing all four. A consent
|
|
567
|
+
* record is a legal record, so a channel the composition asks about
|
|
568
|
+
* carries its own tick, and only the channels nobody asked about fall back
|
|
569
|
+
* to the one blanket answer — which is what a childless fixed form, and
|
|
570
|
+
* every composition that shows a single marketing row, still sends.
|
|
571
|
+
*/
|
|
572
|
+
AUTH_MARKETING_CHANNEL_FLAGS.forEach((name) => {
|
|
573
|
+
payload[name] = authoredTick(name) ?? agreedToMarketing;
|
|
574
|
+
});
|
|
453
575
|
|
|
454
576
|
// Commerce expects bare digits; the mask characters brands render around
|
|
455
577
|
// the phone input would fail its length validation.
|
|
@@ -468,20 +590,62 @@ const RegisterFormBlock = ({
|
|
|
468
590
|
setIsSubmitting(true);
|
|
469
591
|
|
|
470
592
|
try {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
593
|
+
/**
|
|
594
|
+
* One attempt. Called twice at most: a captcha escalation is answered
|
|
595
|
+
* and the same body is sent again, which is what the brand's own view
|
|
596
|
+
* does — the shopper solved the challenge, so making them press the
|
|
597
|
+
* button again would be the storefront's failure, not theirs.
|
|
598
|
+
*/
|
|
599
|
+
const attempt = (solvedNow?: boolean) =>
|
|
600
|
+
signIn(providerId, {
|
|
601
|
+
// Sent only while the form offers a challenge, so a storefront
|
|
602
|
+
// without one keeps posting exactly the body it posts today.
|
|
603
|
+
...(showCaptcha
|
|
604
|
+
? {
|
|
605
|
+
captchaValidated:
|
|
606
|
+
solvedNow ?? captchaRef.current?.validated ?? false
|
|
607
|
+
}
|
|
608
|
+
: null),
|
|
609
|
+
// After the payload, not before it: these decide whether next-auth
|
|
610
|
+
// hands back a response or navigates away, and a body key must never
|
|
611
|
+
// be able to answer that question. `RESERVED_SUBMIT_KEYS` already
|
|
612
|
+
// keeps them out of the payload; this is the belt to that braces.
|
|
613
|
+
...payload,
|
|
614
|
+
redirect: false,
|
|
615
|
+
callbackUrl
|
|
616
|
+
});
|
|
480
617
|
|
|
481
|
-
|
|
482
|
-
|
|
618
|
+
let response = await attempt();
|
|
619
|
+
|
|
620
|
+
if (
|
|
621
|
+
response?.error &&
|
|
622
|
+
showCaptcha &&
|
|
623
|
+
readAuthError(signInErrorDetail(response)).hasCaptchaChallenge &&
|
|
624
|
+
(await captchaRef.current?.validate())
|
|
625
|
+
) {
|
|
626
|
+
// `true`, not the ref: what `validate` just resolved IS the answer, and
|
|
627
|
+
// the ref only catches up on the host's next render.
|
|
628
|
+
response = await attempt(true);
|
|
629
|
+
}
|
|
483
630
|
|
|
484
|
-
|
|
631
|
+
if (!response || response.error) {
|
|
632
|
+
/**
|
|
633
|
+
* What commerce actually refused, split into the sentence for the
|
|
634
|
+
* form-level slot and the sentences that belong under a field.
|
|
635
|
+
*
|
|
636
|
+
* Re-split against the form on screen: commerce names a field this
|
|
637
|
+
* composition may not have authored, and a message hung on a name no
|
|
638
|
+
* control submits under is a message nobody ever sees.
|
|
639
|
+
*/
|
|
640
|
+
const reading = splitByKnownFields(
|
|
641
|
+
readAuthError(signInErrorDetail(response), {
|
|
642
|
+
otp: otpLabel,
|
|
643
|
+
captcha: captchaLabel
|
|
644
|
+
}),
|
|
645
|
+
formElement
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
if (reading.hasOtpChallenge) {
|
|
485
649
|
// Resolved BEFORE the handoff is parked, so the record can say which
|
|
486
650
|
// journey it belongs to.
|
|
487
651
|
const resolvedStep = resolveAuthStepTarget(
|
|
@@ -502,7 +666,7 @@ const RegisterFormBlock = ({
|
|
|
502
666
|
setPendingOtp({
|
|
503
667
|
kind: 'register',
|
|
504
668
|
phone: typeof payload.phone === 'string' ? payload.phone : '',
|
|
505
|
-
providerId
|
|
669
|
+
providerId,
|
|
506
670
|
payload,
|
|
507
671
|
scope: resolvedStep ? resolvedStep.flowId : contextFlowId,
|
|
508
672
|
stepKey: resolvedStep?.stepKey
|
|
@@ -514,13 +678,47 @@ const RegisterFormBlock = ({
|
|
|
514
678
|
}
|
|
515
679
|
}
|
|
516
680
|
|
|
517
|
-
|
|
681
|
+
// Server-side field errors land where client-side ones already do, so
|
|
682
|
+
// "this e-mail is already registered" reads under the e-mail input
|
|
683
|
+
// instead of as a JSON blob above the form.
|
|
684
|
+
setFieldErrors((current) => ({ ...current, ...reading.fieldMessages }));
|
|
685
|
+
focusFirstAuthFieldError(formElement, reading.fieldMessages);
|
|
686
|
+
setError(authFormMessage(reading, errorLabel));
|
|
687
|
+
|
|
518
688
|
return;
|
|
519
689
|
}
|
|
520
690
|
|
|
691
|
+
/**
|
|
692
|
+
* The surface this form finished inside.
|
|
693
|
+
*
|
|
694
|
+
* Closing the modal it is IN is the default because it is never a
|
|
695
|
+
* surprise: a shopper who signed in inside the drawer is done with the
|
|
696
|
+
* drawer. `closeModalsOnSuccess` keeps its own, wider meaning — close
|
|
697
|
+
* EVERY open modal — for a composition that authored it, which is what a
|
|
698
|
+
* form nested two overlays deep needs.
|
|
699
|
+
*
|
|
700
|
+
* The live GANT drawer is why the default had to change: its OTP form has
|
|
701
|
+
* the toggle on and its login and register forms do not, so signing in
|
|
702
|
+
* with an e-mail left the drawer covering the page.
|
|
703
|
+
*/
|
|
704
|
+
/**
|
|
705
|
+
* Both, not either: a composition that ticked the legacy toggle asked
|
|
706
|
+
* for "every open modal", and the wide close is a superset — so the
|
|
707
|
+
* narrow one runs unconditionally and simply finds nothing left to do.
|
|
708
|
+
* Sequencing them the other way round (an `else`) would have left every
|
|
709
|
+
* already-published drawer on the wide branch forever, which is the one
|
|
710
|
+
* behaviour this family exists to stop.
|
|
711
|
+
*/
|
|
521
712
|
if (closeModalsOnSuccess) closeAllModals();
|
|
713
|
+
closeHostModal();
|
|
714
|
+
|
|
715
|
+
// The new session has to reach the page either way; in `stay` mode this
|
|
716
|
+
// refresh IS the whole navigation.
|
|
522
717
|
router.refresh();
|
|
523
|
-
|
|
718
|
+
|
|
719
|
+
// Our own resolved path rather than next-auth's echo of it: this one has
|
|
720
|
+
// been through the same-origin check. `null` is `stay`.
|
|
721
|
+
if (destination) router.push(destination);
|
|
524
722
|
} catch {
|
|
525
723
|
setError(errorLabel);
|
|
526
724
|
} finally {
|
|
@@ -640,9 +838,31 @@ const RegisterFormBlock = ({
|
|
|
640
838
|
* be embedding the storefront — and every copy was a place a new message
|
|
641
839
|
* field had to be remembered.
|
|
642
840
|
*/
|
|
841
|
+
/**
|
|
842
|
+
* What the composition's Form State containers read. A registration ends in
|
|
843
|
+
* a sign-in or a handoff to the OTP step, so there is no success SCREEN
|
|
844
|
+
* here either — what can be scoped is the submission running and the
|
|
845
|
+
* refusal. See `auth/form-state-context.tsx`.
|
|
846
|
+
*/
|
|
847
|
+
const formStateValue: AuthFormStateValue = {
|
|
848
|
+
screen: 'form',
|
|
849
|
+
busy: isSubmitting,
|
|
850
|
+
hasError: Boolean(error)
|
|
851
|
+
};
|
|
852
|
+
|
|
643
853
|
const renderChildren = () =>
|
|
644
854
|
renderComposedChildren({
|
|
645
|
-
|
|
855
|
+
// A child that is not a state container is shared chrome, and shows
|
|
856
|
+
// whatever the form is doing.
|
|
857
|
+
blocks: childBlocks.filter((childBlock) =>
|
|
858
|
+
showsInFormState({
|
|
859
|
+
block: childBlock,
|
|
860
|
+
state: formStateValue,
|
|
861
|
+
isDesigner,
|
|
862
|
+
selectedBlockId,
|
|
863
|
+
currentBreakpoint
|
|
864
|
+
})
|
|
865
|
+
),
|
|
646
866
|
placeholderId,
|
|
647
867
|
sectionId,
|
|
648
868
|
isDesigner,
|
|
@@ -650,6 +870,21 @@ const RegisterFormBlock = ({
|
|
|
650
870
|
currentBreakpoint
|
|
651
871
|
});
|
|
652
872
|
|
|
873
|
+
// Told, rather than shown a form they cannot use — see auth-form-block.
|
|
874
|
+
if (isSignedIn && whenSignedIn !== 'form') {
|
|
875
|
+
return (
|
|
876
|
+
<div
|
|
877
|
+
data-register-form=""
|
|
878
|
+
className="w-full"
|
|
879
|
+
data-register-form-state="signed-in"
|
|
880
|
+
{...partAttrs('signed-in')}
|
|
881
|
+
role="status"
|
|
882
|
+
>
|
|
883
|
+
{signedInLabel}
|
|
884
|
+
</div>
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
653
888
|
if (isComposed) {
|
|
654
889
|
/**
|
|
655
890
|
* The composed form: the children carry the inputs (`auth-field`), the
|
|
@@ -664,6 +899,7 @@ const RegisterFormBlock = ({
|
|
|
664
899
|
them into the flags commerce names individually — so its children
|
|
665
900
|
are where a membership or marketing row actually works. */}
|
|
666
901
|
<AuthConsentHostProvider value>
|
|
902
|
+
<AuthFormStateProvider value={formStateValue}>
|
|
667
903
|
<AuthFieldErrorsProvider value={fieldErrors}>
|
|
668
904
|
<form
|
|
669
905
|
id={formHtmlId || undefined}
|
|
@@ -684,7 +920,17 @@ const RegisterFormBlock = ({
|
|
|
684
920
|
gap: 'var(--register-form-gap, 12px)'
|
|
685
921
|
}}
|
|
686
922
|
>
|
|
687
|
-
{
|
|
923
|
+
{withCaptcha(
|
|
924
|
+
<>
|
|
925
|
+
{renderChildren()}
|
|
926
|
+
|
|
927
|
+
{/* The form draws the challenge only when the composition
|
|
928
|
+
did not place a Security Check block — the same
|
|
929
|
+
relationship the built-in error line has with a Form Error
|
|
930
|
+
block. */}
|
|
931
|
+
{!containsCaptchaBlock(childBlocks) && <AuthCaptchaSlot />}
|
|
932
|
+
</>
|
|
933
|
+
)}
|
|
688
934
|
|
|
689
935
|
{/* The fallback for a composition that has not placed a Form Error
|
|
690
936
|
block; see `auth/form-error-context.tsx` for why placing one is
|
|
@@ -723,6 +969,7 @@ const RegisterFormBlock = ({
|
|
|
723
969
|
)}
|
|
724
970
|
</form>
|
|
725
971
|
</AuthFieldErrorsProvider>
|
|
972
|
+
</AuthFormStateProvider>
|
|
726
973
|
</AuthConsentHostProvider>
|
|
727
974
|
</AuthFormErrorProvider>
|
|
728
975
|
);
|
|
@@ -794,6 +1041,11 @@ const RegisterFormBlock = ({
|
|
|
794
1041
|
false
|
|
795
1042
|
)}
|
|
796
1043
|
|
|
1044
|
+
{/* A childless form has nowhere for a Security Check block to be, so it
|
|
1045
|
+
draws the challenge itself — above its own submit, where the brand's
|
|
1046
|
+
own view puts it. */}
|
|
1047
|
+
{withCaptcha(<AuthCaptchaSlot />)}
|
|
1048
|
+
|
|
797
1049
|
{error && (
|
|
798
1050
|
<div data-register-form-error="" {...partAttrs('error')} role="alert">
|
|
799
1051
|
{error}
|