@akinon/pz-theme 2.0.83-rc.0 → 2.0.84-beta.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 +39 -33
- package/package.json +4 -4
- package/src/__tests__/auth-flow-block.test.tsx +211 -0
- package/src/__tests__/auth-flow-bus.test.ts +290 -0
- package/src/__tests__/auth-form-error-block.test.tsx +326 -0
- package/src/__tests__/auth-form-validation.test.tsx +204 -0
- package/src/__tests__/auth-step-title.test.tsx +202 -0
- package/src/__tests__/detached-submit-state.test.tsx +216 -0
- package/src/__tests__/forgot-password-composed.test.tsx +313 -0
- package/src/__tests__/otp-form-composed.test.tsx +487 -0
- package/src/__tests__/password-reset-step.test.tsx +269 -0
- package/src/__tests__/register-form-composed.test.tsx +739 -0
- package/src/__tests__/registry-parity.test.ts +9 -0
- package/src/auth/auth-field-errors.tsx +151 -0
- package/src/auth/auth-flow-bus.ts +415 -0
- package/src/auth/form-error-context.tsx +55 -0
- package/src/auth/form-submit-bus.ts +93 -0
- package/src/auth/otp-form-context.tsx +80 -0
- package/src/blocks/__tests__/button-block.test.tsx +55 -0
- package/src/blocks/__tests__/link-block.test.tsx +48 -30
- package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
- package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
- package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
- package/src/blocks/__tests__/text-block.test.tsx +37 -3
- package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
- package/src/blocks/auth-consent-block.tsx +214 -0
- package/src/blocks/auth-error-block.tsx +82 -0
- package/src/blocks/auth-field-block.tsx +270 -0
- package/src/blocks/auth-flow-block.tsx +251 -0
- package/src/blocks/auth-form-block.tsx +391 -47
- package/src/blocks/auth-step-block.tsx +81 -0
- package/src/blocks/auth-step-title-block.tsx +81 -0
- package/src/blocks/block-renderer-registry.tsx +18 -0
- package/src/blocks/button-block.tsx +91 -13
- package/src/blocks/forgot-password-block.tsx +318 -24
- package/src/blocks/link-block.tsx +22 -84
- package/src/blocks/otp-code-input-block.tsx +180 -0
- package/src/blocks/otp-form-block.tsx +1092 -0
- package/src/blocks/otp-phase-block.tsx +71 -0
- package/src/blocks/register-form-block.tsx +541 -74
- package/src/blocks/text-block.tsx +17 -40
- package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
- package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
- package/src/hooks/use-block-action.ts +87 -4
- package/src/hooks/use-overlay.ts +60 -9
- package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
- package/src/overlays/__tests__/modal-registry.test.ts +6 -1
- package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
- package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
- package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
- package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
- package/src/overlays/modal-preview-host.tsx +39 -2
- package/src/overlays/modal-registry.ts +344 -4
- package/src/overlays/overlay-surface.tsx +249 -19
- package/src/overlays/overlay-tokens.ts +94 -0
- package/src/overlays/theme-modal-mount.tsx +34 -4
- package/src/theme-block.tsx +6 -0
- package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
- package/src/utils/index.ts +107 -6
- package/src/utils/iterator-utils.ts +39 -2
- package/src/utils/part-styles.ts +4 -0
|
@@ -1,11 +1,37 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState } from 'react';
|
|
3
|
+
import React, { useContext, useState } from 'react';
|
|
4
4
|
import { useRouter } from 'next/navigation';
|
|
5
5
|
import { signIn } from 'next-auth/react';
|
|
6
6
|
import { useLocalization } from '@akinon/next/hooks';
|
|
7
7
|
import { getResponsiveValue, partAttrs } from '../utils';
|
|
8
8
|
import { readToggle } from './product-block-utils';
|
|
9
|
+
import { closeAllModals } from '../overlays/overlay-bus';
|
|
10
|
+
import {
|
|
11
|
+
AuthFieldErrors,
|
|
12
|
+
AuthFieldErrorsProvider,
|
|
13
|
+
clearAuthFieldError,
|
|
14
|
+
collectAuthFieldErrors
|
|
15
|
+
} from '../auth/auth-field-errors';
|
|
16
|
+
import {
|
|
17
|
+
AuthFormErrorProvider,
|
|
18
|
+
containsAuthErrorBlock
|
|
19
|
+
} from '../auth/form-error-context';
|
|
20
|
+
import {
|
|
21
|
+
AUTH_CONSENT_NAMES,
|
|
22
|
+
AuthConsentHostProvider
|
|
23
|
+
} from './auth-consent-block';
|
|
24
|
+
import { renderComposedChildren } from './render-composed-children';
|
|
25
|
+
import {
|
|
26
|
+
clearFormSubmitState,
|
|
27
|
+
publishFormSubmitState
|
|
28
|
+
} from '../auth/form-submit-bus';
|
|
29
|
+
import {
|
|
30
|
+
AuthFlowIdContext,
|
|
31
|
+
resolveAuthStepTarget,
|
|
32
|
+
setAuthStep,
|
|
33
|
+
setPendingOtp
|
|
34
|
+
} from '../auth/auth-flow-bus';
|
|
9
35
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
10
36
|
|
|
11
37
|
/**
|
|
@@ -31,9 +57,10 @@ const readAuthError = (
|
|
|
31
57
|
const data = (entry as { data?: unknown })?.data;
|
|
32
58
|
|
|
33
59
|
// The OTP branch carries no message: superstep answers a valid
|
|
34
|
-
// registration with an `otp` error and expects the storefront to
|
|
35
|
-
// SMS-verification
|
|
36
|
-
// least told what happened instead of
|
|
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.
|
|
37
64
|
if (type === 'otp') {
|
|
38
65
|
messages.push(otpMessage);
|
|
39
66
|
return;
|
|
@@ -59,6 +86,65 @@ const readAuthError = (
|
|
|
59
86
|
}
|
|
60
87
|
};
|
|
61
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Names a composed field must not take, because the form and next-auth already
|
|
91
|
+
* mean something by them.
|
|
92
|
+
*
|
|
93
|
+
* A Custom field carries whatever key the brand's backend expects, which is the
|
|
94
|
+
* point of it — but the sign-up body is not only the shopper's answers: it also
|
|
95
|
+
* says which commerce endpoint to call (`formType`), which language to resolve
|
|
96
|
+
* (`locale`), and how next-auth should behave (`redirect`, `callbackUrl`,
|
|
97
|
+
* `csrfToken`, `json`). A field named after one of those would replace it, and
|
|
98
|
+
* `redirect` in particular is not a broken field but a broken FORM: any string
|
|
99
|
+
* is truthy, so next-auth navigates away instead of handing back the response
|
|
100
|
+
* this block reads its errors from.
|
|
101
|
+
*/
|
|
102
|
+
const RESERVED_SUBMIT_KEYS = new Set([
|
|
103
|
+
'formType',
|
|
104
|
+
'locale',
|
|
105
|
+
'code',
|
|
106
|
+
'redirect',
|
|
107
|
+
'callbackUrl',
|
|
108
|
+
'csrfToken',
|
|
109
|
+
'json'
|
|
110
|
+
]);
|
|
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
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Where the serialised AuthError[] actually lives depends on the next-auth
|
|
135
|
+
* major. v4 puts it into `error` itself; v5 keeps `error` at the generic
|
|
136
|
+
* "CredentialsSignin" enum name and moves the serialised array onto `code` —
|
|
137
|
+
* which is also what the brands' own views read. Preferring `code` is what
|
|
138
|
+
* lets both the message extraction and the OTP-challenge detection work on
|
|
139
|
+
* v5 storefronts.
|
|
140
|
+
*/
|
|
141
|
+
const signInErrorDetail = (
|
|
142
|
+
response:
|
|
143
|
+
| { error?: string | null; code?: string | null }
|
|
144
|
+
| null
|
|
145
|
+
| undefined
|
|
146
|
+
): string | null | undefined => response?.code || response?.error;
|
|
147
|
+
|
|
62
148
|
/**
|
|
63
149
|
* The commerce registration endpoint is reached through next-auth's credentials
|
|
64
150
|
* provider, exactly like the brand's own register view: the provider id is
|
|
@@ -70,19 +156,28 @@ const FORM_TYPE = 'registration';
|
|
|
70
156
|
/**
|
|
71
157
|
* Functional auth block: full registration form (name, surname, e-mail,
|
|
72
158
|
* password, optional phone, consents) submitted through the v1 register path.
|
|
73
|
-
*
|
|
74
|
-
*
|
|
159
|
+
*
|
|
160
|
+
* Composable, the same way the Auth Form is: with children the form draws THEM
|
|
161
|
+
* — an `auth-field` per input, an `auth-consent` per tick, the CTA a Button,
|
|
162
|
+
* the message a Form Error block — and reads the values back out of `FormData`
|
|
163
|
+
* on submit. Left empty it renders the fixed rows exactly as before, so a
|
|
164
|
+
* published composition is untouched. Either way the behaviour is the same
|
|
165
|
+
* commerce sign-up: what composes is the layout, not the journey.
|
|
75
166
|
* Properties: submitLabel, busyLabel, showPhone, showLabels, firstNameLabel,
|
|
76
167
|
* lastNameLabel, emailLabel, passwordLabel, phoneLabel, the matching
|
|
77
168
|
* *Placeholder keys, consentText, marketingConsentText, showMarketingConsent,
|
|
78
|
-
* consentRequiredLabel, errorLabel, otpLabel
|
|
169
|
+
* consentRequiredLabel, errorLabel, otpLabel, otpStepTarget,
|
|
170
|
+
* closeModalsOnSuccess.
|
|
79
171
|
* Parts: form, field, label, input, consent, checkbox, consent-text, error,
|
|
80
172
|
* submit.
|
|
81
173
|
*/
|
|
82
174
|
const RegisterFormBlock = ({
|
|
83
175
|
block,
|
|
176
|
+
placeholderId,
|
|
177
|
+
sectionId,
|
|
84
178
|
currentBreakpoint = 'desktop',
|
|
85
|
-
isDesigner = false
|
|
179
|
+
isDesigner = false,
|
|
180
|
+
selectedBlockId
|
|
86
181
|
}: BlockRendererProps) => {
|
|
87
182
|
const prop = (key: string, fallback: string) =>
|
|
88
183
|
String(
|
|
@@ -93,7 +188,11 @@ const RegisterFormBlock = ({
|
|
|
93
188
|
// Empty on purpose: without a busy label the submit caption stays put while
|
|
94
189
|
// the request runs, which is what published blocks render today.
|
|
95
190
|
const busyLabel = prop('busyLabel', '');
|
|
96
|
-
const showPhone = readToggle(
|
|
191
|
+
const showPhone = readToggle(
|
|
192
|
+
block.properties?.showPhone,
|
|
193
|
+
currentBreakpoint,
|
|
194
|
+
true
|
|
195
|
+
);
|
|
97
196
|
const showLabels = readToggle(
|
|
98
197
|
block.properties?.showLabels,
|
|
99
198
|
currentBreakpoint,
|
|
@@ -112,6 +211,13 @@ const RegisterFormBlock = ({
|
|
|
112
211
|
'marketingConsentText',
|
|
113
212
|
'I would like to receive campaign and marketing messages.'
|
|
114
213
|
);
|
|
214
|
+
/**
|
|
215
|
+
* The copy for a field left empty or filled in wrongly — the brand's own
|
|
216
|
+
* sentence in place of the browser's bubble. See `auth-field-errors.tsx` for
|
|
217
|
+
* why the form validates instead of letting the browser do it.
|
|
218
|
+
*/
|
|
219
|
+
const requiredMessage = prop('requiredMessage', 'This field is required.');
|
|
220
|
+
const invalidMessage = prop('invalidMessage', 'Please check this field.');
|
|
115
221
|
const consentRequiredLabel = prop(
|
|
116
222
|
'consentRequiredLabel',
|
|
117
223
|
'Please accept the membership agreement to continue.'
|
|
@@ -121,9 +227,44 @@ const RegisterFormBlock = ({
|
|
|
121
227
|
'otpLabel',
|
|
122
228
|
'A verification code was sent. Please confirm it to finish.'
|
|
123
229
|
);
|
|
230
|
+
// Where the SMS-verification challenge sends the shopper: a step key (or
|
|
231
|
+
// `flow:step`) whose step holds an `otp-form` block. Empty keeps the old
|
|
232
|
+
// behaviour — the otpLabel message — so published compositions are
|
|
233
|
+
// untouched.
|
|
234
|
+
const otpStepTarget = prop('otpStepTarget', '');
|
|
235
|
+
// Off by default: a register form on a plain page must not start closing
|
|
236
|
+
// modals it does not live in. Compositions inside a drawer turn it on.
|
|
237
|
+
const closeModalsOnSuccess = readToggle(
|
|
238
|
+
block.properties?.closeModalsOnSuccess,
|
|
239
|
+
currentBreakpoint,
|
|
240
|
+
false
|
|
241
|
+
);
|
|
242
|
+
/**
|
|
243
|
+
* Detached submit — same contract as auth-form-block: `formHtmlId` names
|
|
244
|
+
* the <form> so an external Button (native `form` attribute) can submit it
|
|
245
|
+
* from a pinned bottom bar; `showSubmit` off hides the built-in button.
|
|
246
|
+
*/
|
|
247
|
+
const formHtmlId = prop('formHtmlId', '').trim();
|
|
248
|
+
const showSubmit = readToggle(
|
|
249
|
+
block.properties?.showSubmit,
|
|
250
|
+
currentBreakpoint,
|
|
251
|
+
true
|
|
252
|
+
);
|
|
124
253
|
|
|
125
254
|
const router = useRouter();
|
|
126
255
|
const { locale, defaultLocaleValue } = useLocalization();
|
|
256
|
+
const contextFlowId = useContext(AuthFlowIdContext);
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The children a composition authored. Hidden ones are dropped for a shopper
|
|
260
|
+
* and kept in the canvas, so an author can dress a row they have switched
|
|
261
|
+
* off — the same rule every composed block here follows.
|
|
262
|
+
*/
|
|
263
|
+
const childBlocks = (block.blocks || [])
|
|
264
|
+
.filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
|
|
265
|
+
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
266
|
+
const isComposed = childBlocks.length > 0;
|
|
267
|
+
const errorIsAuthored = containsAuthErrorBlock(childBlocks);
|
|
127
268
|
|
|
128
269
|
const [values, setValues] = useState({
|
|
129
270
|
first_name: '',
|
|
@@ -135,23 +276,75 @@ const RegisterFormBlock = ({
|
|
|
135
276
|
const [legalConsent, setLegalConsent] = useState(false);
|
|
136
277
|
const [marketingConsent, setMarketingConsent] = useState(false);
|
|
137
278
|
const [error, setError] = useState('');
|
|
279
|
+
// Per FIELD, as opposed to `error`, which is what the backend answered about
|
|
280
|
+
// the attempt as a whole.
|
|
281
|
+
const [fieldErrors, setFieldErrors] = useState<AuthFieldErrors>({});
|
|
138
282
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
139
283
|
|
|
140
|
-
const setField =
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
};
|
|
284
|
+
const setField = (key: keyof typeof values) => (
|
|
285
|
+
event: React.ChangeEvent<HTMLInputElement>
|
|
286
|
+
) => {
|
|
287
|
+
const nextValue = event.target.value;
|
|
288
|
+
setValues((current) => ({ ...current, [key]: nextValue }));
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A message describes what a field WAS: it goes the moment that field is
|
|
293
|
+
* edited. Both events, because a keystroke raises `input` while autofill and
|
|
294
|
+
* a checkbox raise `change`.
|
|
295
|
+
*/
|
|
296
|
+
/**
|
|
297
|
+
* A detached CTA (a Button carrying this form's id) is outside this subtree
|
|
298
|
+
* and cannot see `isSubmitting` — so the form publishes it. See
|
|
299
|
+
* `auth/form-submit-bus.ts`.
|
|
300
|
+
*/
|
|
301
|
+
React.useEffect(() => {
|
|
302
|
+
if (!formHtmlId) return undefined;
|
|
303
|
+
|
|
304
|
+
publishFormSubmitState(formHtmlId, isSubmitting);
|
|
305
|
+
|
|
306
|
+
return () => clearFormSubmitState(formHtmlId);
|
|
307
|
+
}, [formHtmlId, isSubmitting]);
|
|
308
|
+
|
|
309
|
+
const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
|
|
310
|
+
const target = event.target as { name?: string } | null;
|
|
311
|
+
|
|
312
|
+
setFieldErrors((current) =>
|
|
313
|
+
clearAuthFieldError(current, target?.name || '')
|
|
314
|
+
);
|
|
315
|
+
};
|
|
145
316
|
|
|
146
|
-
const submit = async (event: React.FormEvent) => {
|
|
317
|
+
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
147
318
|
event.preventDefault();
|
|
148
319
|
setError('');
|
|
149
320
|
|
|
150
321
|
// Submitting inside the designer would create a real commerce account.
|
|
151
322
|
if (isDesigner) return;
|
|
152
323
|
|
|
153
|
-
|
|
154
|
-
|
|
324
|
+
// Held onto because `currentTarget` is only the form for as long as the
|
|
325
|
+
// handler is on the stack, and the consent question below is asked of the
|
|
326
|
+
// markup rather than of state.
|
|
327
|
+
const formElement = event.currentTarget;
|
|
328
|
+
|
|
329
|
+
// The form carries `noValidate`, so this is what stops an empty submit —
|
|
330
|
+
// and unlike the browser's bubble it says which field, in the brand's
|
|
331
|
+
// words, under the field, until the field is fixed.
|
|
332
|
+
const { errors: nextFieldErrors, firstInvalid } = collectAuthFieldErrors(
|
|
333
|
+
event.currentTarget,
|
|
334
|
+
{ required: requiredMessage, invalid: invalidMessage }
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
// The consent tick has a sentence of its own, and it is a better one than
|
|
338
|
+
// "this field is required" for a legal opt-in.
|
|
339
|
+
const withConsentCopy = nextFieldErrors.is_allowed
|
|
340
|
+
? { ...nextFieldErrors, is_allowed: consentRequiredLabel }
|
|
341
|
+
: nextFieldErrors;
|
|
342
|
+
|
|
343
|
+
setFieldErrors(withConsentCopy);
|
|
344
|
+
|
|
345
|
+
if (firstInvalid) {
|
|
346
|
+
firstInvalid.focus();
|
|
347
|
+
|
|
155
348
|
return;
|
|
156
349
|
}
|
|
157
350
|
|
|
@@ -159,45 +352,173 @@ const RegisterFormBlock = ({
|
|
|
159
352
|
formType: FORM_TYPE,
|
|
160
353
|
// The credentials provider looks the locale up in Settings and throws
|
|
161
354
|
// when it is missing, so an explicit value is always sent.
|
|
162
|
-
locale: locale || defaultLocaleValue || ''
|
|
163
|
-
first_name: values.first_name,
|
|
164
|
-
last_name: values.last_name,
|
|
165
|
-
email: values.email,
|
|
166
|
-
password: values.password,
|
|
167
|
-
// Commerce requires both consent flags; one checkbox gates them because
|
|
168
|
-
// both are mandatory to register at all.
|
|
169
|
-
is_allowed: true,
|
|
170
|
-
confirm: true,
|
|
171
|
-
email_allowed: marketingConsent,
|
|
172
|
-
sms_allowed: marketingConsent,
|
|
173
|
-
call_allowed: marketingConsent,
|
|
174
|
-
whatsapp_allowed: marketingConsent
|
|
355
|
+
locale: locale || defaultLocaleValue || ''
|
|
175
356
|
};
|
|
176
357
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Who answered the two consent questions: the composition's own
|
|
360
|
+
* `auth-consent` blocks, or this block's fixed rows.
|
|
361
|
+
*
|
|
362
|
+
* A checkbox that is not ticked is simply absent from `FormData` — that is
|
|
363
|
+
* the browser's own answer to "did they agree", so it is read rather than
|
|
364
|
+
* mirrored into state. The names come from the consent block so a
|
|
365
|
+
* composition and a fixed form post the same body.
|
|
366
|
+
*/
|
|
367
|
+
let agreedToTerms = legalConsent;
|
|
368
|
+
let agreedToMarketing = marketingConsent;
|
|
369
|
+
|
|
370
|
+
if (isComposed) {
|
|
371
|
+
const entries = new FormData(event.currentTarget);
|
|
372
|
+
|
|
373
|
+
entries.forEach((entryValue, key) => {
|
|
374
|
+
if (typeof entryValue !== 'string') return;
|
|
375
|
+
|
|
376
|
+
// A field the author named after part of the protocol would not be an
|
|
377
|
+
// extra answer, it would be a different request: `formType` decides
|
|
378
|
+
// which commerce endpoint is called and `redirect` decides whether
|
|
379
|
+
// next-auth returns a response at all. The brand's own field is
|
|
380
|
+
// dropped rather than allowed to redirect the sign-up.
|
|
381
|
+
if (RESERVED_SUBMIT_KEYS.has(key)) return;
|
|
382
|
+
|
|
383
|
+
// An answer the shopper did not give is an answer the body should not
|
|
384
|
+
// carry: a gender left on its empty option and an untouched date input
|
|
385
|
+
// both post `''`, which is neither a choice nor a date. The fixed rows
|
|
386
|
+
// never sent these keys, and the block already deletes `permissions`
|
|
387
|
+
// rather than send a falsy one.
|
|
388
|
+
if (entryValue === '') return;
|
|
389
|
+
|
|
390
|
+
// First one wins per name, which is the rule `collectAuthFieldErrors`
|
|
391
|
+
// already applies to the same duplicates — a field the author copied,
|
|
392
|
+
// or a breakpoint twin, left empty. Taking the LAST meant the empty
|
|
393
|
+
// twin erased what the shopper actually typed.
|
|
394
|
+
if (key in payload) return;
|
|
395
|
+
|
|
396
|
+
payload[key] = entryValue;
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
agreedToTerms = entries.get(AUTH_CONSENT_NAMES.legal) !== null;
|
|
400
|
+
agreedToMarketing = entries.get(AUTH_CONSENT_NAMES.marketing) !== null;
|
|
401
|
+
|
|
402
|
+
// The ticks are re-expressed as the flags below, so the raw checkbox
|
|
403
|
+
// entries must not also travel — `is_allowed: 'on'` is not what commerce
|
|
404
|
+
// reads, and `permissions` has to be absent rather than falsy.
|
|
405
|
+
delete payload[AUTH_CONSENT_NAMES.legal];
|
|
406
|
+
delete payload[AUTH_CONSENT_NAMES.marketing];
|
|
407
|
+
} else {
|
|
408
|
+
payload.first_name = values.first_name;
|
|
409
|
+
payload.last_name = values.last_name;
|
|
410
|
+
payload.email = values.email;
|
|
411
|
+
payload.password = values.password;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Whether the agreement is a question this composition actually asks, and
|
|
416
|
+
* whether it insists on a yes.
|
|
417
|
+
*
|
|
418
|
+
* The refusal used to be unconditional, which made two authored states
|
|
419
|
+
* impossible rather than optional: a Consent Row with Required turned off
|
|
420
|
+
* still could not be left unticked (the editor's toggle says "turn it off
|
|
421
|
+
* for an optional tick", and the block's own docstring promises a brand in
|
|
422
|
+
* a market where the agreement is not mandatory can say so), and a
|
|
423
|
+
* composition with no consent row at all could never submit — the shopper
|
|
424
|
+
* was told to accept an agreement that was nowhere on screen.
|
|
425
|
+
*
|
|
426
|
+
* No row means the brand is not asking, which is what the fixed rows do
|
|
427
|
+
* for a childless form, so the flag goes out the way it always has.
|
|
428
|
+
*/
|
|
429
|
+
const legalControl = isComposed
|
|
430
|
+
? (formElement.querySelector(
|
|
431
|
+
`input[name="${AUTH_CONSENT_NAMES.legal}"]`
|
|
432
|
+
) as HTMLInputElement | null)
|
|
433
|
+
: null;
|
|
434
|
+
const legalIsMandatory = isComposed
|
|
435
|
+
? Boolean(legalControl?.required)
|
|
436
|
+
: true;
|
|
437
|
+
|
|
438
|
+
if (legalIsMandatory && !agreedToTerms) {
|
|
439
|
+
setError(consentRequiredLabel);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
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;
|
|
453
|
+
|
|
454
|
+
// Commerce expects bare digits; the mask characters brands render around
|
|
455
|
+
// the phone input would fail its length validation.
|
|
456
|
+
if (isComposed) {
|
|
457
|
+
if (typeof payload.phone === 'string') {
|
|
458
|
+
payload.phone = payload.phone.replace(/[^\d]/g, '');
|
|
459
|
+
}
|
|
460
|
+
} else if (showPhone) {
|
|
180
461
|
payload.phone = values.phone.replace(/[^\d]/g, '');
|
|
181
462
|
}
|
|
182
463
|
|
|
183
464
|
// `permissions` is only meaningful when opted in — the brand deletes the
|
|
184
465
|
// key entirely rather than sending a falsy value.
|
|
185
|
-
if (
|
|
466
|
+
if (agreedToMarketing) payload.permissions = 'on';
|
|
186
467
|
|
|
187
468
|
setIsSubmitting(true);
|
|
188
469
|
|
|
189
470
|
try {
|
|
190
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,
|
|
191
477
|
redirect: false,
|
|
192
|
-
callbackUrl: '/'
|
|
193
|
-
...payload
|
|
478
|
+
callbackUrl: '/'
|
|
194
479
|
});
|
|
195
480
|
|
|
196
481
|
if (!response || response.error) {
|
|
197
|
-
|
|
482
|
+
const errorDetail = signInErrorDetail(response);
|
|
483
|
+
|
|
484
|
+
if (hasOtpChallenge(errorDetail)) {
|
|
485
|
+
// Resolved BEFORE the handoff is parked, so the record can say which
|
|
486
|
+
// journey it belongs to.
|
|
487
|
+
const resolvedStep = resolveAuthStepTarget(
|
|
488
|
+
otpStepTarget,
|
|
489
|
+
contextFlowId
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
// Commerce accepted the sign-up and wants an SMS code. The full
|
|
493
|
+
// payload is parked on the auth bus so an `otp-form` block — in the
|
|
494
|
+
// targeted step, or anywhere on the page — can re-submit it with
|
|
495
|
+
// the code and finish what this form started.
|
|
496
|
+
//
|
|
497
|
+
// Scoped, because the payload holds a password and the page has one
|
|
498
|
+
// slot: without it a header's phone-login drawer, opened later,
|
|
499
|
+
// would find this record, open on its code screen and re-post
|
|
500
|
+
// someone else's registration. `stepKey` is what lets navigating the
|
|
501
|
+
// flow elsewhere abandon a journey nobody came back to.
|
|
502
|
+
setPendingOtp({
|
|
503
|
+
kind: 'register',
|
|
504
|
+
phone: typeof payload.phone === 'string' ? payload.phone : '',
|
|
505
|
+
providerId: PROVIDER_ID,
|
|
506
|
+
payload,
|
|
507
|
+
scope: resolvedStep ? resolvedStep.flowId : contextFlowId,
|
|
508
|
+
stepKey: resolvedStep?.stepKey
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
if (resolvedStep) {
|
|
512
|
+
setAuthStep(resolvedStep.flowId, resolvedStep.stepKey);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
setError(readAuthError(errorDetail, otpLabel) || errorLabel);
|
|
198
518
|
return;
|
|
199
519
|
}
|
|
200
520
|
|
|
521
|
+
if (closeModalsOnSuccess) closeAllModals();
|
|
201
522
|
router.refresh();
|
|
202
523
|
router.push(response.url || '/');
|
|
203
524
|
} catch {
|
|
@@ -239,10 +560,29 @@ const RegisterFormBlock = ({
|
|
|
239
560
|
autoComplete={autoComplete}
|
|
240
561
|
placeholder={placeholder || undefined}
|
|
241
562
|
aria-label={showLabels ? undefined : label}
|
|
563
|
+
aria-invalid={fieldErrors[key] ? true : undefined}
|
|
242
564
|
required
|
|
243
565
|
{...partAttrs('input')}
|
|
244
|
-
style={
|
|
566
|
+
style={{
|
|
567
|
+
...inputStyle,
|
|
568
|
+
...(fieldErrors[key]
|
|
569
|
+
? { borderColor: 'var(--register-form-border-invalid, #dc2626)' }
|
|
570
|
+
: null)
|
|
571
|
+
}}
|
|
245
572
|
/>
|
|
573
|
+
{fieldErrors[key] && (
|
|
574
|
+
<span
|
|
575
|
+
data-register-form-field-error={key}
|
|
576
|
+
{...partAttrs('error')}
|
|
577
|
+
role="alert"
|
|
578
|
+
style={{
|
|
579
|
+
color: 'var(--register-form-error-color, #dc2626)',
|
|
580
|
+
fontSize: '0.875em'
|
|
581
|
+
}}
|
|
582
|
+
>
|
|
583
|
+
{fieldErrors[key]}
|
|
584
|
+
</span>
|
|
585
|
+
)}
|
|
246
586
|
</label>
|
|
247
587
|
);
|
|
248
588
|
|
|
@@ -253,29 +593,154 @@ const RegisterFormBlock = ({
|
|
|
253
593
|
name: string,
|
|
254
594
|
required: boolean
|
|
255
595
|
) => (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
596
|
+
<>
|
|
597
|
+
<label
|
|
598
|
+
data-register-form-consent={name}
|
|
599
|
+
{...partAttrs('consent')}
|
|
600
|
+
style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}
|
|
601
|
+
>
|
|
602
|
+
<input
|
|
603
|
+
type="checkbox"
|
|
604
|
+
name={name}
|
|
605
|
+
checked={checked}
|
|
606
|
+
onChange={(event) => onChange(event.target.checked)}
|
|
607
|
+
aria-invalid={fieldErrors[name] ? true : undefined}
|
|
608
|
+
required={required}
|
|
609
|
+
{...partAttrs('checkbox', { checked })}
|
|
610
|
+
style={{ marginTop: '3px' }}
|
|
611
|
+
/>
|
|
612
|
+
<span {...partAttrs('consent-text')}>{label}</span>
|
|
613
|
+
</label>
|
|
614
|
+
{/* Outside the label rather than inside it: the row is a checkbox beside
|
|
615
|
+
its text, and a message wedged in there would sit between them. */}
|
|
616
|
+
{fieldErrors[name] && (
|
|
617
|
+
<span
|
|
618
|
+
data-register-form-field-error={name}
|
|
619
|
+
{...partAttrs('error')}
|
|
620
|
+
role="alert"
|
|
621
|
+
style={{
|
|
622
|
+
color: 'var(--register-form-error-color, #dc2626)',
|
|
623
|
+
fontSize: '0.875em'
|
|
624
|
+
}}
|
|
625
|
+
>
|
|
626
|
+
{fieldErrors[name]}
|
|
627
|
+
</span>
|
|
628
|
+
)}
|
|
629
|
+
</>
|
|
272
630
|
);
|
|
273
631
|
|
|
632
|
+
/**
|
|
633
|
+
* The children, with the six editor callbacks each one needs to be movable,
|
|
634
|
+
* duplicable, hideable, deletable and renamable on the canvas.
|
|
635
|
+
*
|
|
636
|
+
* Through the package's own `renderComposedChildren` rather than a private
|
|
637
|
+
* copy of it. The copies each posted their messages to `'*'`, which
|
|
638
|
+
* `editor-bridge.ts` exists to prevent — it addresses the editor's real
|
|
639
|
+
* origin, so an editor command never reaches whatever other page happens to
|
|
640
|
+
* be embedding the storefront — and every copy was a place a new message
|
|
641
|
+
* field had to be remembered.
|
|
642
|
+
*/
|
|
643
|
+
const renderChildren = () =>
|
|
644
|
+
renderComposedChildren({
|
|
645
|
+
blocks: childBlocks,
|
|
646
|
+
placeholderId,
|
|
647
|
+
sectionId,
|
|
648
|
+
isDesigner,
|
|
649
|
+
selectedBlockId,
|
|
650
|
+
currentBreakpoint
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
if (isComposed) {
|
|
654
|
+
/**
|
|
655
|
+
* The composed form: the children carry the inputs (`auth-field`), the
|
|
656
|
+
* ticks (`auth-consent`) and whatever chrome the design wants around them,
|
|
657
|
+
* and submit reads the lot back out of `FormData`. The built-in button
|
|
658
|
+
* stays available under its toggle so a fresh composition works before the
|
|
659
|
+
* designer has wired their own CTA.
|
|
660
|
+
*/
|
|
661
|
+
return (
|
|
662
|
+
<AuthFormErrorProvider value={error}>
|
|
663
|
+
{/* This form is the one that ANSWERS the two known ticks — turning
|
|
664
|
+
them into the flags commerce names individually — so its children
|
|
665
|
+
are where a membership or marketing row actually works. */}
|
|
666
|
+
<AuthConsentHostProvider value>
|
|
667
|
+
<AuthFieldErrorsProvider value={fieldErrors}>
|
|
668
|
+
<form
|
|
669
|
+
id={formHtmlId || undefined}
|
|
670
|
+
data-register-form=""
|
|
671
|
+
data-register-form-composed=""
|
|
672
|
+
// Fills its block wrapper; see the note in auth-step-block.tsx.
|
|
673
|
+
className="w-full"
|
|
674
|
+
{...partAttrs('form')}
|
|
675
|
+
onSubmit={submit}
|
|
676
|
+
// Our own message under the field instead of the browser's bubble
|
|
677
|
+
// over it; the constraints it reads stay on the controls.
|
|
678
|
+
noValidate
|
|
679
|
+
onInput={handleInput}
|
|
680
|
+
onChange={handleInput}
|
|
681
|
+
style={{
|
|
682
|
+
display: 'flex',
|
|
683
|
+
flexDirection: 'column',
|
|
684
|
+
gap: 'var(--register-form-gap, 12px)'
|
|
685
|
+
}}
|
|
686
|
+
>
|
|
687
|
+
{renderChildren()}
|
|
688
|
+
|
|
689
|
+
{/* The fallback for a composition that has not placed a Form Error
|
|
690
|
+
block; see `auth/form-error-context.tsx` for why placing one is
|
|
691
|
+
better than this. */}
|
|
692
|
+
{error && !errorIsAuthored && (
|
|
693
|
+
<div
|
|
694
|
+
data-register-form-error=""
|
|
695
|
+
{...partAttrs('error')}
|
|
696
|
+
role="alert"
|
|
697
|
+
>
|
|
698
|
+
{error}
|
|
699
|
+
</div>
|
|
700
|
+
)}
|
|
701
|
+
|
|
702
|
+
{showSubmit && (
|
|
703
|
+
<button
|
|
704
|
+
type="submit"
|
|
705
|
+
data-register-form-submit=""
|
|
706
|
+
{...partAttrs('submit', {
|
|
707
|
+
loading: isSubmitting,
|
|
708
|
+
disabled: isSubmitting
|
|
709
|
+
})}
|
|
710
|
+
disabled={isSubmitting}
|
|
711
|
+
aria-busy={isSubmitting}
|
|
712
|
+
style={{
|
|
713
|
+
padding: '12px 20px',
|
|
714
|
+
background: 'var(--register-form-cta-bg, #111827)',
|
|
715
|
+
color: 'var(--register-form-cta-color, #ffffff)',
|
|
716
|
+
border: 'none',
|
|
717
|
+
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
|
718
|
+
opacity: isSubmitting ? 0.7 : 1
|
|
719
|
+
}}
|
|
720
|
+
>
|
|
721
|
+
{isSubmitting && busyLabel ? busyLabel : submitLabel}
|
|
722
|
+
</button>
|
|
723
|
+
)}
|
|
724
|
+
</form>
|
|
725
|
+
</AuthFieldErrorsProvider>
|
|
726
|
+
</AuthConsentHostProvider>
|
|
727
|
+
</AuthFormErrorProvider>
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
274
731
|
return (
|
|
275
732
|
<form
|
|
733
|
+
id={formHtmlId || undefined}
|
|
276
734
|
data-register-form=""
|
|
735
|
+
// Fills its block wrapper; see the note in auth-step-block.tsx.
|
|
736
|
+
className="w-full"
|
|
277
737
|
{...partAttrs('form')}
|
|
278
738
|
onSubmit={submit}
|
|
739
|
+
// Our own message under the field instead of the browser's bubble over
|
|
740
|
+
// it; the constraints it reads stay on the controls.
|
|
741
|
+
noValidate
|
|
742
|
+
onInput={handleInput}
|
|
743
|
+
onChange={handleInput}
|
|
279
744
|
style={{
|
|
280
745
|
display: 'flex',
|
|
281
746
|
flexDirection: 'column',
|
|
@@ -335,26 +800,28 @@ const RegisterFormBlock = ({
|
|
|
335
800
|
</div>
|
|
336
801
|
)}
|
|
337
802
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
803
|
+
{showSubmit && (
|
|
804
|
+
<button
|
|
805
|
+
type="submit"
|
|
806
|
+
data-register-form-submit=""
|
|
807
|
+
{...partAttrs('submit', {
|
|
808
|
+
loading: isSubmitting,
|
|
809
|
+
disabled: isSubmitting
|
|
810
|
+
})}
|
|
811
|
+
disabled={isSubmitting}
|
|
812
|
+
aria-busy={isSubmitting}
|
|
813
|
+
style={{
|
|
814
|
+
padding: '12px 20px',
|
|
815
|
+
background: 'var(--register-form-cta-bg, #111827)',
|
|
816
|
+
color: 'var(--register-form-cta-color, #ffffff)',
|
|
817
|
+
border: 'none',
|
|
818
|
+
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
|
819
|
+
opacity: isSubmitting ? 0.7 : 1
|
|
820
|
+
}}
|
|
821
|
+
>
|
|
822
|
+
{isSubmitting && busyLabel ? busyLabel : submitLabel}
|
|
823
|
+
</button>
|
|
824
|
+
)}
|
|
358
825
|
</form>
|
|
359
826
|
);
|
|
360
827
|
};
|