@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,32 +1,22 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useContext,
|
|
4
|
-
import {
|
|
5
|
-
import { signIn
|
|
3
|
+
import React, { useContext, useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { signIn } from 'next-auth/react';
|
|
6
6
|
import { useLocalization } from '@akinon/next/hooks';
|
|
7
|
-
import { getResponsiveValue, partAttrs
|
|
7
|
+
import { getResponsiveValue, partAttrs } 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';
|
|
14
10
|
import {
|
|
15
11
|
AuthFieldErrors,
|
|
16
12
|
AuthFieldErrorsProvider,
|
|
17
13
|
clearAuthFieldError,
|
|
18
|
-
collectAuthFieldErrors
|
|
19
|
-
focusFirstAuthFieldError
|
|
14
|
+
collectAuthFieldErrors
|
|
20
15
|
} from '../auth/auth-field-errors';
|
|
21
16
|
import {
|
|
22
17
|
AuthFormErrorProvider,
|
|
23
18
|
containsAuthErrorBlock
|
|
24
19
|
} from '../auth/form-error-context';
|
|
25
|
-
import {
|
|
26
|
-
AuthFormStateProvider,
|
|
27
|
-
AuthFormStateValue,
|
|
28
|
-
showsInFormState
|
|
29
|
-
} from '../auth/form-state-context';
|
|
30
20
|
import {
|
|
31
21
|
AUTH_CONSENT_NAMES,
|
|
32
22
|
AuthConsentHostProvider
|
|
@@ -42,29 +32,60 @@ import {
|
|
|
42
32
|
setAuthStep,
|
|
43
33
|
setPendingOtp
|
|
44
34
|
} 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';
|
|
66
35
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
67
36
|
|
|
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
|
+
|
|
68
89
|
/**
|
|
69
90
|
* Names a composed field must not take, because the form and next-auth already
|
|
70
91
|
* mean something by them.
|
|
@@ -88,6 +109,27 @@ const RESERVED_SUBMIT_KEYS = new Set([
|
|
|
88
109
|
'json'
|
|
89
110
|
]);
|
|
90
111
|
|
|
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
|
+
|
|
91
133
|
/**
|
|
92
134
|
* Where the serialised AuthError[] actually lives depends on the next-auth
|
|
93
135
|
* major. v4 puts it into `error` itself; v5 keeps `error` at the generic
|
|
@@ -105,27 +147,11 @@ const signInErrorDetail = (
|
|
|
105
147
|
|
|
106
148
|
/**
|
|
107
149
|
* The commerce registration endpoint is reached through next-auth's credentials
|
|
108
|
-
* provider, exactly like the brand's own register view:
|
|
109
|
-
* `
|
|
110
|
-
* see `providerId` / `formType` in the renderer — because the compiled-in
|
|
111
|
-
* `registration` is not a key the core url map has.
|
|
150
|
+
* provider, exactly like the brand's own register view: the provider id is
|
|
151
|
+
* `default` and the url segment is selected by `formType`.
|
|
112
152
|
*/
|
|
113
|
-
const
|
|
114
|
-
const
|
|
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;
|
|
153
|
+
const PROVIDER_ID = 'default';
|
|
154
|
+
const FORM_TYPE = 'registration';
|
|
129
155
|
|
|
130
156
|
/**
|
|
131
157
|
* Functional auth block: full registration form (name, surname, e-mail,
|
|
@@ -158,25 +184,6 @@ const RegisterFormBlock = ({
|
|
|
158
184
|
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
159
185
|
);
|
|
160
186
|
|
|
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
|
-
|
|
180
187
|
const submitLabel = prop('submitLabel', 'Create account');
|
|
181
188
|
// Empty on purpose: without a busy label the submit caption stays put while
|
|
182
189
|
// the request runs, which is what published blocks render today.
|
|
@@ -220,54 +227,6 @@ const RegisterFormBlock = ({
|
|
|
220
227
|
'otpLabel',
|
|
221
228
|
'A verification code was sent. Please confirm it to finish.'
|
|
222
229
|
);
|
|
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');
|
|
271
230
|
// Where the SMS-verification challenge sends the shopper: a step key (or
|
|
272
231
|
// `flow:step`) whose step holds an `otp-form` block. Empty keeps the old
|
|
273
232
|
// behaviour — the otpLabel message — so published compositions are
|
|
@@ -293,20 +252,6 @@ const RegisterFormBlock = ({
|
|
|
293
252
|
);
|
|
294
253
|
|
|
295
254
|
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
|
-
});
|
|
310
255
|
const { locale, defaultLocaleValue } = useLocalization();
|
|
311
256
|
const contextFlowId = useContext(AuthFlowIdContext);
|
|
312
257
|
|
|
@@ -334,37 +279,6 @@ const RegisterFormBlock = ({
|
|
|
334
279
|
// Per FIELD, as opposed to `error`, which is what the backend answered about
|
|
335
280
|
// the attempt as a whole.
|
|
336
281
|
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
|
-
);
|
|
368
282
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
369
283
|
|
|
370
284
|
const setField = (key: keyof typeof values) => (
|
|
@@ -407,14 +321,6 @@ const RegisterFormBlock = ({
|
|
|
407
321
|
// Submitting inside the designer would create a real commerce account.
|
|
408
322
|
if (isDesigner) return;
|
|
409
323
|
|
|
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
|
-
|
|
418
324
|
// Held onto because `currentTarget` is only the form for as long as the
|
|
419
325
|
// handler is on the stack, and the consent question below is asked of the
|
|
420
326
|
// markup rather than of state.
|
|
@@ -443,7 +349,7 @@ const RegisterFormBlock = ({
|
|
|
443
349
|
}
|
|
444
350
|
|
|
445
351
|
const payload: Record<string, string | boolean> = {
|
|
446
|
-
formType,
|
|
352
|
+
formType: FORM_TYPE,
|
|
447
353
|
// The credentials provider looks the locale up in Settings and throws
|
|
448
354
|
// when it is missing, so an explicit value is always sent.
|
|
449
355
|
locale: locale || defaultLocaleValue || ''
|
|
@@ -534,44 +440,16 @@ const RegisterFormBlock = ({
|
|
|
534
440
|
return;
|
|
535
441
|
}
|
|
536
442
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
});
|
|
443
|
+
// Commerce requires both consent flags; one tick gates them because both
|
|
444
|
+
// are mandatory to register at all — unless the composition made the tick
|
|
445
|
+
// optional, and then the shopper's actual answer is what travels rather
|
|
446
|
+
// than a yes nobody gave.
|
|
447
|
+
payload.is_allowed = legalControl ? agreedToTerms : true;
|
|
448
|
+
payload.confirm = payload.is_allowed;
|
|
449
|
+
payload.email_allowed = agreedToMarketing;
|
|
450
|
+
payload.sms_allowed = agreedToMarketing;
|
|
451
|
+
payload.call_allowed = agreedToMarketing;
|
|
452
|
+
payload.whatsapp_allowed = agreedToMarketing;
|
|
575
453
|
|
|
576
454
|
// Commerce expects bare digits; the mask characters brands render around
|
|
577
455
|
// the phone input would fail its length validation.
|
|
@@ -590,62 +468,20 @@ const RegisterFormBlock = ({
|
|
|
590
468
|
setIsSubmitting(true);
|
|
591
469
|
|
|
592
470
|
try {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
});
|
|
617
|
-
|
|
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
|
-
}
|
|
471
|
+
const response = await signIn(PROVIDER_ID, {
|
|
472
|
+
// After the payload, not before it: these decide whether next-auth
|
|
473
|
+
// hands back a response or navigates away, and a body key must never
|
|
474
|
+
// be able to answer that question. `RESERVED_SUBMIT_KEYS` already
|
|
475
|
+
// keeps them out of the payload; this is the belt to that braces.
|
|
476
|
+
...payload,
|
|
477
|
+
redirect: false,
|
|
478
|
+
callbackUrl: '/'
|
|
479
|
+
});
|
|
630
480
|
|
|
631
481
|
if (!response || response.error) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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) {
|
|
482
|
+
const errorDetail = signInErrorDetail(response);
|
|
483
|
+
|
|
484
|
+
if (hasOtpChallenge(errorDetail)) {
|
|
649
485
|
// Resolved BEFORE the handoff is parked, so the record can say which
|
|
650
486
|
// journey it belongs to.
|
|
651
487
|
const resolvedStep = resolveAuthStepTarget(
|
|
@@ -666,7 +502,7 @@ const RegisterFormBlock = ({
|
|
|
666
502
|
setPendingOtp({
|
|
667
503
|
kind: 'register',
|
|
668
504
|
phone: typeof payload.phone === 'string' ? payload.phone : '',
|
|
669
|
-
providerId,
|
|
505
|
+
providerId: PROVIDER_ID,
|
|
670
506
|
payload,
|
|
671
507
|
scope: resolvedStep ? resolvedStep.flowId : contextFlowId,
|
|
672
508
|
stepKey: resolvedStep?.stepKey
|
|
@@ -678,47 +514,13 @@ const RegisterFormBlock = ({
|
|
|
678
514
|
}
|
|
679
515
|
}
|
|
680
516
|
|
|
681
|
-
|
|
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
|
-
|
|
517
|
+
setError(readAuthError(errorDetail, otpLabel) || errorLabel);
|
|
688
518
|
return;
|
|
689
519
|
}
|
|
690
520
|
|
|
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
|
-
*/
|
|
712
521
|
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.
|
|
717
522
|
router.refresh();
|
|
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);
|
|
523
|
+
router.push(response.url || '/');
|
|
722
524
|
} catch {
|
|
723
525
|
setError(errorLabel);
|
|
724
526
|
} finally {
|
|
@@ -838,31 +640,9 @@ const RegisterFormBlock = ({
|
|
|
838
640
|
* be embedding the storefront — and every copy was a place a new message
|
|
839
641
|
* field had to be remembered.
|
|
840
642
|
*/
|
|
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
|
-
|
|
853
643
|
const renderChildren = () =>
|
|
854
644
|
renderComposedChildren({
|
|
855
|
-
|
|
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
|
-
),
|
|
645
|
+
blocks: childBlocks,
|
|
866
646
|
placeholderId,
|
|
867
647
|
sectionId,
|
|
868
648
|
isDesigner,
|
|
@@ -870,21 +650,6 @@ const RegisterFormBlock = ({
|
|
|
870
650
|
currentBreakpoint
|
|
871
651
|
});
|
|
872
652
|
|
|
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
|
-
|
|
888
653
|
if (isComposed) {
|
|
889
654
|
/**
|
|
890
655
|
* The composed form: the children carry the inputs (`auth-field`), the
|
|
@@ -899,7 +664,6 @@ const RegisterFormBlock = ({
|
|
|
899
664
|
them into the flags commerce names individually — so its children
|
|
900
665
|
are where a membership or marketing row actually works. */}
|
|
901
666
|
<AuthConsentHostProvider value>
|
|
902
|
-
<AuthFormStateProvider value={formStateValue}>
|
|
903
667
|
<AuthFieldErrorsProvider value={fieldErrors}>
|
|
904
668
|
<form
|
|
905
669
|
id={formHtmlId || undefined}
|
|
@@ -920,17 +684,7 @@ const RegisterFormBlock = ({
|
|
|
920
684
|
gap: 'var(--register-form-gap, 12px)'
|
|
921
685
|
}}
|
|
922
686
|
>
|
|
923
|
-
{
|
|
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
|
-
)}
|
|
687
|
+
{renderChildren()}
|
|
934
688
|
|
|
935
689
|
{/* The fallback for a composition that has not placed a Form Error
|
|
936
690
|
block; see `auth/form-error-context.tsx` for why placing one is
|
|
@@ -969,7 +723,6 @@ const RegisterFormBlock = ({
|
|
|
969
723
|
)}
|
|
970
724
|
</form>
|
|
971
725
|
</AuthFieldErrorsProvider>
|
|
972
|
-
</AuthFormStateProvider>
|
|
973
726
|
</AuthConsentHostProvider>
|
|
974
727
|
</AuthFormErrorProvider>
|
|
975
728
|
);
|
|
@@ -1041,11 +794,6 @@ const RegisterFormBlock = ({
|
|
|
1041
794
|
false
|
|
1042
795
|
)}
|
|
1043
796
|
|
|
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
|
-
|
|
1049
797
|
{error && (
|
|
1050
798
|
<div data-register-form-error="" {...partAttrs('error')} role="alert">
|
|
1051
799
|
{error}
|
|
@@ -63,9 +63,6 @@ export const PLACEHOLDER_SLUGS = {
|
|
|
63
63
|
// Auth
|
|
64
64
|
register: 'register-page-body',
|
|
65
65
|
forgotPassword: 'forgot-password-body',
|
|
66
|
-
// The screen the reset MAIL lands on — a different page from the one that
|
|
67
|
-
// ASKS for the mail. `reset-password-block` is what fills it.
|
|
68
|
-
resetPassword: 'reset-password-body',
|
|
69
66
|
guestLogin: 'guest-login-body',
|
|
70
67
|
|
|
71
68
|
// Content
|
package/src/chrome/route-slug.ts
CHANGED
|
@@ -71,13 +71,6 @@ const DEFAULT_RULES: RouteSlugRule[] = [
|
|
|
71
71
|
PLACEHOLDER_SLUGS.forgotPassword
|
|
72
72
|
],
|
|
73
73
|
[/^\/users\/password\/reset(\/|$)/, PLACEHOLDER_SLUGS.forgotPassword],
|
|
74
|
-
// The screen the reset MAIL lands on, which is a different page from the one
|
|
75
|
-
// above: that one ASKS for the mail, this one is where the link goes. The
|
|
76
|
-
// token rides in the path as a catch-all — and may itself contain slashes —
|
|
77
|
-
// so the rule stops at the first boundary and lets the block read the rest.
|
|
78
|
-
// Ordering is safe either way: `/users/password/reset` and `/users/reset`
|
|
79
|
-
// cannot both match a path.
|
|
80
|
-
[/^\/users\/reset(\/|$)/, PLACEHOLDER_SLUGS.resetPassword],
|
|
81
74
|
[/^\/(users\/)?auth\/guest(\/|$)/, PLACEHOLDER_SLUGS.guestLogin],
|
|
82
75
|
[/^\/(users\/)?auth\/?$/, PLACEHOLDER_SLUGS.auth],
|
|
83
76
|
|
|
@@ -150,10 +143,6 @@ const PAGE_MOUNTED_SLUGS = new Set<string>([
|
|
|
150
143
|
PLACEHOLDER_SLUGS.auth,
|
|
151
144
|
PLACEHOLDER_SLUGS.register,
|
|
152
145
|
PLACEHOLDER_SLUGS.forgotPassword,
|
|
153
|
-
// Mounted by its own layout beside the catch-all page that reads the token,
|
|
154
|
-
// for the same reason the two above are: the page is a client component, so
|
|
155
|
-
// the handover cannot live in it.
|
|
156
|
-
PLACEHOLDER_SLUGS.resetPassword,
|
|
157
146
|
PLACEHOLDER_SLUGS.guestLogin,
|
|
158
147
|
PLACEHOLDER_SLUGS.flatPage,
|
|
159
148
|
// Mounted by the not-found boundary itself, which renders on any unmatched
|