@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,13 +1,34 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState } from 'react';
|
|
3
|
+
import React, { useContext, useState } from 'react';
|
|
4
4
|
import NextLink from 'next/link';
|
|
5
5
|
import { useRouter } from 'next/navigation';
|
|
6
6
|
import { signIn } from 'next-auth/react';
|
|
7
7
|
import { useLocalization } from '@akinon/next/hooks';
|
|
8
8
|
import { getResponsiveValue, partAttrs } from '../utils';
|
|
9
|
+
import {
|
|
10
|
+
AuthFieldErrors,
|
|
11
|
+
AuthFieldErrorsProvider,
|
|
12
|
+
clearAuthFieldError,
|
|
13
|
+
collectAuthFieldErrors
|
|
14
|
+
} from '../auth/auth-field-errors';
|
|
15
|
+
import {
|
|
16
|
+
clearFormSubmitState,
|
|
17
|
+
publishFormSubmitState
|
|
18
|
+
} from '../auth/form-submit-bus';
|
|
19
|
+
import {
|
|
20
|
+
AuthFormErrorProvider,
|
|
21
|
+
containsAuthErrorBlock
|
|
22
|
+
} from '../auth/form-error-context';
|
|
9
23
|
import { emitCommerceEvent } from '../utils/analytics';
|
|
10
24
|
import { readToggle } from './product-block-utils';
|
|
25
|
+
import { closeAllModals } from '../overlays/overlay-bus';
|
|
26
|
+
import {
|
|
27
|
+
AuthFlowIdContext,
|
|
28
|
+
resolveAuthStepTarget,
|
|
29
|
+
setAuthStep
|
|
30
|
+
} from '../auth/auth-flow-bus';
|
|
31
|
+
import { renderComposedChildren } from './render-composed-children';
|
|
11
32
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
12
33
|
|
|
13
34
|
/**
|
|
@@ -48,6 +69,36 @@ const readAuthError = (error?: string | null): string => {
|
|
|
48
69
|
}
|
|
49
70
|
};
|
|
50
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Names a composed field must not take, because the form and next-auth already
|
|
74
|
+
* mean something by them. A local copy of the register form's set, kept local
|
|
75
|
+
* for the same reason `readAuthError` is: making it an export would change a
|
|
76
|
+
* shipped module's public surface.
|
|
77
|
+
*/
|
|
78
|
+
const RESERVED_SUBMIT_KEYS = new Set([
|
|
79
|
+
'formType',
|
|
80
|
+
'locale',
|
|
81
|
+
'code',
|
|
82
|
+
'redirect',
|
|
83
|
+
'callbackUrl',
|
|
84
|
+
'csrfToken',
|
|
85
|
+
'json'
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Where the serialised AuthError[] actually lives depends on the next-auth
|
|
90
|
+
* major. v4 puts it into `error` itself; v5 keeps `error` at the generic
|
|
91
|
+
* "CredentialsSignin" enum name and moves the serialised array onto `code` —
|
|
92
|
+
* which is also what the brands' own views read. Preferring `code` shows the
|
|
93
|
+
* shopper the real commerce message on v5 storefronts instead of the enum.
|
|
94
|
+
*/
|
|
95
|
+
const signInErrorDetail = (
|
|
96
|
+
response:
|
|
97
|
+
| { error?: string | null; code?: string | null }
|
|
98
|
+
| null
|
|
99
|
+
| undefined
|
|
100
|
+
): string | null | undefined => response?.code || response?.error;
|
|
101
|
+
|
|
51
102
|
/**
|
|
52
103
|
* Functional auth block: login or register form driven by props.mode.
|
|
53
104
|
*
|
|
@@ -61,13 +112,17 @@ const readAuthError = (error?: string | null): string => {
|
|
|
61
112
|
* Properties: mode, submitLabel, busyLabel, providerId, loginFormType,
|
|
62
113
|
* registerFormType, callbackUrl, emailLabel, passwordLabel, firstNameLabel,
|
|
63
114
|
* lastNameLabel, the matching *Placeholder keys, showLabels, errorLabel,
|
|
64
|
-
* showForgotPassword, forgotPasswordLabel, forgotPasswordUrl
|
|
115
|
+
* showForgotPassword, forgotPasswordLabel, forgotPasswordUrl,
|
|
116
|
+
* closeModalsOnSuccess.
|
|
65
117
|
* Parts: form, field, label, input, error, submit, link.
|
|
66
118
|
*/
|
|
67
119
|
const AuthFormBlock = ({
|
|
68
120
|
block,
|
|
69
|
-
|
|
70
|
-
|
|
121
|
+
placeholderId,
|
|
122
|
+
sectionId,
|
|
123
|
+
isDesigner = false,
|
|
124
|
+
selectedBlockId,
|
|
125
|
+
currentBreakpoint = 'desktop'
|
|
71
126
|
}: BlockRendererProps) => {
|
|
72
127
|
const prop = (key: string, fallback: string) =>
|
|
73
128
|
String(
|
|
@@ -98,13 +153,55 @@ const AuthFormBlock = ({
|
|
|
98
153
|
);
|
|
99
154
|
const forgotPasswordLabel = prop('forgotPasswordLabel', 'Forgot password?');
|
|
100
155
|
const forgotPasswordUrl = prop('forgotPasswordUrl', '/auth/forgot-password');
|
|
156
|
+
/**
|
|
157
|
+
* Where "forgot password?" goes when the form lives inside an Auth Flow.
|
|
158
|
+
*
|
|
159
|
+
* The link navigates by default, which is right on an auth PAGE and wrong in
|
|
160
|
+
* a drawer: it closes the journey the shopper was two fields into. Naming a
|
|
161
|
+
* step keeps them inside the flow — put a Forgot Password block in that step.
|
|
162
|
+
* Empty keeps the URL, so every published form behaves as before.
|
|
163
|
+
*/
|
|
164
|
+
const forgotPasswordStepTarget = prop('forgotPasswordStepTarget', '').trim();
|
|
101
165
|
const formType =
|
|
102
166
|
mode === 'register'
|
|
103
167
|
? prop('registerFormType', 'registration')
|
|
104
168
|
: prop('loginFormType', 'login');
|
|
169
|
+
// Off by default: a form on a plain auth page must not start closing modals
|
|
170
|
+
// it does not live in. Compositions inside a drawer turn it on so a login
|
|
171
|
+
// does not leave the drawer covering the page the shopper just entered.
|
|
172
|
+
const closeModalsOnSuccess = readToggle(
|
|
173
|
+
block.properties?.closeModalsOnSuccess,
|
|
174
|
+
currentBreakpoint,
|
|
175
|
+
false
|
|
176
|
+
);
|
|
177
|
+
/**
|
|
178
|
+
* Detached submit: `formHtmlId` puts an id on the <form> so a Button block
|
|
179
|
+
* anywhere on the page can submit it through the native `form` attribute —
|
|
180
|
+
* which is how a design pins the CTA to a drawer's bottom bar while the
|
|
181
|
+
* fields stay in the flow. `showSubmit` off hides the built-in button then;
|
|
182
|
+
* Enter in a field still submits, as it does in any form.
|
|
183
|
+
*/
|
|
184
|
+
/**
|
|
185
|
+
* The copy for a field left empty or filled in wrongly. Editor properties
|
|
186
|
+
* because these are shopper-facing sentences in the brand's own language —
|
|
187
|
+
* replacing the browser's wording is the whole point of validating here.
|
|
188
|
+
*/
|
|
189
|
+
const requiredMessage = prop('requiredMessage', 'This field is required.');
|
|
190
|
+
const invalidMessage = prop('invalidMessage', 'Please check this field.');
|
|
191
|
+
const formHtmlId = prop('formHtmlId', '').trim();
|
|
192
|
+
const showSubmit = readToggle(
|
|
193
|
+
block.properties?.showSubmit,
|
|
194
|
+
currentBreakpoint,
|
|
195
|
+
true
|
|
196
|
+
);
|
|
105
197
|
|
|
106
198
|
const router = useRouter();
|
|
107
199
|
const { locale, defaultLocaleValue } = useLocalization();
|
|
200
|
+
const contextFlowId = useContext(AuthFlowIdContext);
|
|
201
|
+
const forgotPasswordStep = resolveAuthStepTarget(
|
|
202
|
+
forgotPasswordStepTarget,
|
|
203
|
+
contextFlowId
|
|
204
|
+
);
|
|
108
205
|
|
|
109
206
|
const [values, setValues] = useState({
|
|
110
207
|
email: '',
|
|
@@ -113,6 +210,9 @@ const AuthFormBlock = ({
|
|
|
113
210
|
last_name: ''
|
|
114
211
|
});
|
|
115
212
|
const [error, setError] = useState('');
|
|
213
|
+
// Per FIELD, as opposed to `error` above, which is what the auth backend
|
|
214
|
+
// answered about the attempt as a whole.
|
|
215
|
+
const [fieldErrors, setFieldErrors] = useState<AuthFieldErrors>({});
|
|
116
216
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
117
217
|
|
|
118
218
|
const setField = (key: keyof typeof values) => (
|
|
@@ -122,37 +222,114 @@ const AuthFormBlock = ({
|
|
|
122
222
|
setValues((current) => ({ ...current, [key]: nextValue }));
|
|
123
223
|
};
|
|
124
224
|
|
|
125
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Composed mode: the form's fields are CHILD blocks (auth-field and
|
|
227
|
+
* whatever the designer arranged between them), so the values live in the
|
|
228
|
+
* DOM, not in this component's state — FormData reads every named input at
|
|
229
|
+
* submit time. That is what makes the field order, the links between
|
|
230
|
+
* fields and the CTA placement free arrangement instead of a fixed layout.
|
|
231
|
+
*/
|
|
232
|
+
const childBlocks = (block.blocks || [])
|
|
233
|
+
.filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
|
|
234
|
+
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
235
|
+
const isComposed = childBlocks.length > 0;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A message describes what a field WAS: the moment the shopper edits it, it
|
|
239
|
+
* goes. `input` bubbles, so one handler on the form covers every field a
|
|
240
|
+
* composition put inside it, at any depth — and it is wired to `change` as
|
|
241
|
+
* well, for the edits that arrive without keystrokes (browser autofill, a
|
|
242
|
+
* select).
|
|
243
|
+
*/
|
|
244
|
+
/**
|
|
245
|
+
* A detached CTA (a Button carrying this form's id) is outside this subtree
|
|
246
|
+
* and cannot see `isSubmitting` — so the form publishes it. See
|
|
247
|
+
* `auth/form-submit-bus.ts`.
|
|
248
|
+
*/
|
|
249
|
+
React.useEffect(() => {
|
|
250
|
+
if (!formHtmlId) return undefined;
|
|
251
|
+
|
|
252
|
+
publishFormSubmitState(formHtmlId, isSubmitting);
|
|
253
|
+
|
|
254
|
+
return () => clearFormSubmitState(formHtmlId);
|
|
255
|
+
}, [formHtmlId, isSubmitting]);
|
|
256
|
+
|
|
257
|
+
const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
|
|
258
|
+
const target = event.target as { name?: string } | null;
|
|
259
|
+
|
|
260
|
+
setFieldErrors((current) =>
|
|
261
|
+
clearAuthFieldError(current, target?.name || '')
|
|
262
|
+
);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
126
266
|
event.preventDefault();
|
|
127
267
|
setError('');
|
|
128
268
|
|
|
129
|
-
if (isDesigner) return;
|
|
269
|
+
if (isDesigner || isSubmitting) return;
|
|
270
|
+
|
|
271
|
+
// Before anything is sent: the form carries `noValidate`, so nothing else
|
|
272
|
+
// is going to stop an empty submit, and nothing else would have told the
|
|
273
|
+
// shopper WHICH field is the problem.
|
|
274
|
+
const form = event.currentTarget;
|
|
275
|
+
const { errors: nextFieldErrors, firstInvalid } = collectAuthFieldErrors(
|
|
276
|
+
form,
|
|
277
|
+
{ required: requiredMessage, invalid: invalidMessage }
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
setFieldErrors(nextFieldErrors);
|
|
281
|
+
|
|
282
|
+
if (firstInvalid) {
|
|
283
|
+
firstInvalid.focus();
|
|
284
|
+
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
130
287
|
|
|
131
288
|
const payload: Record<string, string> = {
|
|
132
289
|
formType,
|
|
133
290
|
// The core credentials provider looks the locale up in Settings and
|
|
134
291
|
// throws when it is missing, so an explicit value is always sent.
|
|
135
|
-
locale: locale || defaultLocaleValue || ''
|
|
136
|
-
email: values.email,
|
|
137
|
-
password: values.password
|
|
292
|
+
locale: locale || defaultLocaleValue || ''
|
|
138
293
|
};
|
|
139
294
|
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
295
|
+
if (isComposed) {
|
|
296
|
+
new FormData(event.currentTarget).forEach((entryValue, key) => {
|
|
297
|
+
if (typeof entryValue !== 'string') return;
|
|
298
|
+
|
|
299
|
+
// Same three rules the register form applies, and for the same
|
|
300
|
+
// reasons: a field named after part of the protocol would change the
|
|
301
|
+
// request rather than add to it, an answer nobody gave should not
|
|
302
|
+
// travel as an empty string, and where a name is duplicated the FIRST
|
|
303
|
+
// control wins — which is what `collectAuthFieldErrors` already does
|
|
304
|
+
// with the same duplicate.
|
|
305
|
+
if (RESERVED_SUBMIT_KEYS.has(key)) return;
|
|
306
|
+
if (entryValue === '') return;
|
|
307
|
+
if (key in payload) return;
|
|
308
|
+
|
|
309
|
+
payload[key] = entryValue;
|
|
310
|
+
});
|
|
311
|
+
} else {
|
|
312
|
+
payload.email = values.email;
|
|
313
|
+
payload.password = values.password;
|
|
314
|
+
|
|
315
|
+
if (mode === 'register') {
|
|
316
|
+
payload.first_name = values.first_name;
|
|
317
|
+
payload.last_name = values.last_name;
|
|
318
|
+
}
|
|
143
319
|
}
|
|
144
320
|
|
|
145
321
|
setIsSubmitting(true);
|
|
146
322
|
|
|
147
323
|
try {
|
|
148
324
|
const response = await signIn(providerId, {
|
|
325
|
+
// After the payload: these decide whether next-auth answers at all.
|
|
326
|
+
...payload,
|
|
149
327
|
redirect: false,
|
|
150
|
-
callbackUrl
|
|
151
|
-
...payload
|
|
328
|
+
callbackUrl
|
|
152
329
|
});
|
|
153
330
|
|
|
154
331
|
if (!response || response.error) {
|
|
155
|
-
setError(readAuthError(response
|
|
332
|
+
setError(readAuthError(signInErrorDetail(response)) || errorLabel);
|
|
156
333
|
|
|
157
334
|
return;
|
|
158
335
|
}
|
|
@@ -163,6 +340,7 @@ const AuthFormBlock = ({
|
|
|
163
340
|
method: providerId
|
|
164
341
|
});
|
|
165
342
|
|
|
343
|
+
if (closeModalsOnSuccess) closeAllModals();
|
|
166
344
|
router.refresh();
|
|
167
345
|
router.push(callbackUrl);
|
|
168
346
|
} catch {
|
|
@@ -204,19 +382,195 @@ const AuthFormBlock = ({
|
|
|
204
382
|
autoComplete={autoComplete}
|
|
205
383
|
placeholder={placeholder || undefined}
|
|
206
384
|
aria-label={showLabels ? undefined : label}
|
|
385
|
+
aria-invalid={fieldErrors[key] ? true : undefined}
|
|
207
386
|
required
|
|
208
387
|
{...partAttrs('input')}
|
|
209
|
-
style={
|
|
388
|
+
style={{
|
|
389
|
+
...inputStyle,
|
|
390
|
+
...(fieldErrors[key]
|
|
391
|
+
? { borderColor: 'var(--auth-form-border-invalid, #dc2626)' }
|
|
392
|
+
: null)
|
|
393
|
+
}}
|
|
210
394
|
/>
|
|
395
|
+
{fieldErrors[key] && (
|
|
396
|
+
<span
|
|
397
|
+
data-auth-form-field-error={key}
|
|
398
|
+
{...partAttrs('error')}
|
|
399
|
+
role="alert"
|
|
400
|
+
style={{
|
|
401
|
+
color: 'var(--auth-form-error-color, #dc2626)',
|
|
402
|
+
fontSize: '0.875em'
|
|
403
|
+
}}
|
|
404
|
+
>
|
|
405
|
+
{fieldErrors[key]}
|
|
406
|
+
</span>
|
|
407
|
+
)}
|
|
211
408
|
</label>
|
|
212
409
|
);
|
|
213
410
|
|
|
411
|
+
/**
|
|
412
|
+
* "Forgot password?", in both modes.
|
|
413
|
+
*
|
|
414
|
+
* Composed forms draw it too: the fields being child blocks says nothing
|
|
415
|
+
* about the chrome around them — the submit button and the form-level error
|
|
416
|
+
* are drawn in both modes for the same reason. Left out, `showForgotPassword`
|
|
417
|
+
* and the Link part group meant one thing on a fixed form and nothing at all
|
|
418
|
+
* on a composed one, which is the kind of difference a designer discovers by
|
|
419
|
+
* publishing.
|
|
420
|
+
*/
|
|
421
|
+
const forgotPasswordControl =
|
|
422
|
+
showForgotPassword && forgotPasswordLabel ? (
|
|
423
|
+
forgotPasswordStep ? (
|
|
424
|
+
// A button, not a link: this moves the flow, it does not navigate —
|
|
425
|
+
// and `type="button"` keeps it from submitting the form it sits in.
|
|
426
|
+
// Styled to read as the link it replaces.
|
|
427
|
+
<button
|
|
428
|
+
type="button"
|
|
429
|
+
data-auth-form-link="reset-password"
|
|
430
|
+
{...partAttrs('link')}
|
|
431
|
+
onClick={() => {
|
|
432
|
+
// The canvas has its own step switcher; a click here must not move
|
|
433
|
+
// the flow under the designer's feet.
|
|
434
|
+
if (isDesigner) return;
|
|
435
|
+
|
|
436
|
+
setAuthStep(forgotPasswordStep.flowId, forgotPasswordStep.stepKey);
|
|
437
|
+
}}
|
|
438
|
+
style={{
|
|
439
|
+
alignSelf: 'flex-start',
|
|
440
|
+
padding: 0,
|
|
441
|
+
margin: 0,
|
|
442
|
+
border: 'none',
|
|
443
|
+
background: 'transparent',
|
|
444
|
+
color: 'inherit',
|
|
445
|
+
font: 'inherit',
|
|
446
|
+
textDecoration: 'underline',
|
|
447
|
+
cursor: 'pointer'
|
|
448
|
+
}}
|
|
449
|
+
>
|
|
450
|
+
{forgotPasswordLabel}
|
|
451
|
+
</button>
|
|
452
|
+
) : (
|
|
453
|
+
<NextLink
|
|
454
|
+
data-auth-form-link="reset-password"
|
|
455
|
+
{...partAttrs('link')}
|
|
456
|
+
href={isDesigner ? '#' : forgotPasswordUrl}
|
|
457
|
+
onClick={(event) => {
|
|
458
|
+
if (isDesigner) event.preventDefault();
|
|
459
|
+
}}
|
|
460
|
+
style={{ color: 'inherit' }}
|
|
461
|
+
>
|
|
462
|
+
{forgotPasswordLabel}
|
|
463
|
+
</NextLink>
|
|
464
|
+
)
|
|
465
|
+
) : null;
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The children, with the six editor callbacks each one needs to be movable,
|
|
469
|
+
* duplicable, hideable, deletable and renamable on the canvas.
|
|
470
|
+
*
|
|
471
|
+
* Through the package's own `renderComposedChildren` rather than a private
|
|
472
|
+
* copy of it. The copies each posted their messages to `'*'`, which
|
|
473
|
+
* `editor-bridge.ts` exists to prevent — it addresses the editor's real
|
|
474
|
+
* origin, so an editor command never reaches whatever other page happens to
|
|
475
|
+
* be embedding the storefront — and every copy was a place a new message
|
|
476
|
+
* field had to be remembered.
|
|
477
|
+
*/
|
|
478
|
+
const renderChildren = () =>
|
|
479
|
+
renderComposedChildren({
|
|
480
|
+
blocks: childBlocks,
|
|
481
|
+
placeholderId,
|
|
482
|
+
sectionId,
|
|
483
|
+
isDesigner,
|
|
484
|
+
selectedBlockId,
|
|
485
|
+
currentBreakpoint
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
if (isComposed) {
|
|
489
|
+
/**
|
|
490
|
+
* The composed form: children carry the fields (native inputs named by
|
|
491
|
+
* `auth-field`), submit reads them through FormData. The built-in submit
|
|
492
|
+
* stays available under its toggle so a fresh composition works before
|
|
493
|
+
* the designer wires their own CTA.
|
|
494
|
+
*/
|
|
495
|
+
const errorIsAuthored = containsAuthErrorBlock(childBlocks);
|
|
496
|
+
|
|
497
|
+
return (
|
|
498
|
+
<AuthFormErrorProvider value={error}>
|
|
499
|
+
<AuthFieldErrorsProvider value={fieldErrors}>
|
|
500
|
+
<form
|
|
501
|
+
id={formHtmlId || undefined}
|
|
502
|
+
data-auth-form=""
|
|
503
|
+
// Fills its block wrapper; see the note in auth-step-block.tsx.
|
|
504
|
+
className="w-full"
|
|
505
|
+
data-auth-form-mode={mode}
|
|
506
|
+
{...partAttrs('form')}
|
|
507
|
+
onSubmit={submit}
|
|
508
|
+
// The browser's own bubble is what this replaces; the constraints it
|
|
509
|
+
// reads (`required`, `type`) stay on the fields and are still what
|
|
510
|
+
// `collectAuthFieldErrors` reports on.
|
|
511
|
+
noValidate
|
|
512
|
+
onInput={handleInput}
|
|
513
|
+
onChange={handleInput}
|
|
514
|
+
style={{
|
|
515
|
+
display: 'flex',
|
|
516
|
+
flexDirection: 'column',
|
|
517
|
+
gap: 'var(--auth-form-gap, 12px)'
|
|
518
|
+
}}
|
|
519
|
+
>
|
|
520
|
+
{renderChildren()}
|
|
521
|
+
|
|
522
|
+
{forgotPasswordControl}
|
|
523
|
+
|
|
524
|
+
{/* The composition's own Form Error block draws the message where the
|
|
525
|
+
design wants it; this node is the fallback for one that has not
|
|
526
|
+
placed it, and appending it here is exactly why the block exists.
|
|
527
|
+
See `auth/form-error-context.tsx`. */}
|
|
528
|
+
{error && !errorIsAuthored && (
|
|
529
|
+
<div data-auth-form-error="" {...partAttrs('error')} role="alert">
|
|
530
|
+
{error}
|
|
531
|
+
</div>
|
|
532
|
+
)}
|
|
533
|
+
|
|
534
|
+
{showSubmit && (
|
|
535
|
+
<button
|
|
536
|
+
type="submit"
|
|
537
|
+
data-auth-form-submit=""
|
|
538
|
+
{...partAttrs('submit', {
|
|
539
|
+
loading: isSubmitting,
|
|
540
|
+
disabled: isSubmitting
|
|
541
|
+
})}
|
|
542
|
+
disabled={isSubmitting}
|
|
543
|
+
style={{
|
|
544
|
+
padding: '12px 20px',
|
|
545
|
+
background: 'var(--auth-form-cta-bg, #111827)',
|
|
546
|
+
color: 'var(--auth-form-cta-color, #ffffff)',
|
|
547
|
+
border: 'none',
|
|
548
|
+
cursor: 'pointer'
|
|
549
|
+
}}
|
|
550
|
+
>
|
|
551
|
+
{isSubmitting && busyLabel ? busyLabel : submitLabel}
|
|
552
|
+
</button>
|
|
553
|
+
)}
|
|
554
|
+
</form>
|
|
555
|
+
</AuthFieldErrorsProvider>
|
|
556
|
+
</AuthFormErrorProvider>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
214
560
|
return (
|
|
215
561
|
<form
|
|
562
|
+
id={formHtmlId || undefined}
|
|
216
563
|
data-auth-form=""
|
|
564
|
+
// Fills its block wrapper; see the note in auth-step-block.tsx.
|
|
565
|
+
className="w-full"
|
|
217
566
|
data-auth-form-mode={mode}
|
|
218
567
|
{...partAttrs('form')}
|
|
219
568
|
onSubmit={submit}
|
|
569
|
+
// Same reasoning as the composed form above: our own message under the
|
|
570
|
+
// field, not the browser's bubble over it.
|
|
571
|
+
noValidate
|
|
572
|
+
onInput={handleInput}
|
|
573
|
+
onChange={handleInput}
|
|
220
574
|
style={{
|
|
221
575
|
display: 'flex',
|
|
222
576
|
flexDirection: 'column',
|
|
@@ -257,19 +611,7 @@ const AuthFormBlock = ({
|
|
|
257
611
|
prop('passwordPlaceholder', '')
|
|
258
612
|
)}
|
|
259
613
|
|
|
260
|
-
{
|
|
261
|
-
<NextLink
|
|
262
|
-
data-auth-form-link="reset-password"
|
|
263
|
-
{...partAttrs('link')}
|
|
264
|
-
href={isDesigner ? '#' : forgotPasswordUrl}
|
|
265
|
-
onClick={(event) => {
|
|
266
|
-
if (isDesigner) event.preventDefault();
|
|
267
|
-
}}
|
|
268
|
-
style={{ color: 'inherit' }}
|
|
269
|
-
>
|
|
270
|
-
{forgotPasswordLabel}
|
|
271
|
-
</NextLink>
|
|
272
|
-
)}
|
|
614
|
+
{forgotPasswordControl}
|
|
273
615
|
|
|
274
616
|
{error && (
|
|
275
617
|
<div data-auth-form-error="" {...partAttrs('error')} role="alert">
|
|
@@ -277,24 +619,26 @@ const AuthFormBlock = ({
|
|
|
277
619
|
</div>
|
|
278
620
|
)}
|
|
279
621
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
622
|
+
{showSubmit && (
|
|
623
|
+
<button
|
|
624
|
+
type="submit"
|
|
625
|
+
data-auth-form-submit=""
|
|
626
|
+
{...partAttrs('submit', {
|
|
627
|
+
loading: isSubmitting,
|
|
628
|
+
disabled: isSubmitting
|
|
629
|
+
})}
|
|
630
|
+
disabled={isSubmitting}
|
|
631
|
+
style={{
|
|
632
|
+
padding: '12px 20px',
|
|
633
|
+
background: 'var(--auth-form-cta-bg, #111827)',
|
|
634
|
+
color: 'var(--auth-form-cta-color, #ffffff)',
|
|
635
|
+
border: 'none',
|
|
636
|
+
cursor: 'pointer'
|
|
637
|
+
}}
|
|
638
|
+
>
|
|
639
|
+
{isSubmitting && busyLabel ? busyLabel : submitLabel}
|
|
640
|
+
</button>
|
|
641
|
+
)}
|
|
298
642
|
</form>
|
|
299
643
|
);
|
|
300
644
|
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
5
|
+
import { renderComposedChildren } from './render-composed-children';
|
|
6
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
|
+
import { DesignerPlaceholder } from './product-block-utils';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One step of an auth journey: a named wrapper whose contents are authored as
|
|
11
|
+
* CHILD blocks — a login form, an OTP form, plain text and buttons, whatever
|
|
12
|
+
* the design says the step is.
|
|
13
|
+
*
|
|
14
|
+
* The step itself decides nothing about visibility. Inside an `auth-flow`
|
|
15
|
+
* block the FLOW renders only the active step; dropped anywhere else the step
|
|
16
|
+
* is just a labelled column and its children always show — which is what makes
|
|
17
|
+
* every step reusable on its own (a register step straight on a page, a login
|
|
18
|
+
* step inside any panel).
|
|
19
|
+
*
|
|
20
|
+
* Properties: stepKey.
|
|
21
|
+
* Parts: step.
|
|
22
|
+
*/
|
|
23
|
+
const AuthStepBlock = ({
|
|
24
|
+
block,
|
|
25
|
+
placeholderId,
|
|
26
|
+
sectionId,
|
|
27
|
+
isDesigner = false,
|
|
28
|
+
selectedBlockId,
|
|
29
|
+
currentBreakpoint = 'desktop'
|
|
30
|
+
}: BlockRendererProps) => {
|
|
31
|
+
const stepKey = String(
|
|
32
|
+
getResponsiveValue(block.properties?.stepKey, currentBreakpoint, '')
|
|
33
|
+
).trim();
|
|
34
|
+
|
|
35
|
+
const childBlocks = (block.blocks || [])
|
|
36
|
+
.filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
|
|
37
|
+
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
38
|
+
|
|
39
|
+
// An empty step renders nothing a shopper could see; in the canvas it has to
|
|
40
|
+
// say what it is waiting for, or adding one looks like nothing happened.
|
|
41
|
+
if (childBlocks.length === 0 && !isDesigner) return null;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
data-auth-step=""
|
|
46
|
+
// Fills the block wrapper the designer selects.
|
|
47
|
+
//
|
|
48
|
+
// That wrapper is a flex ROW by default (the Visibility control ships
|
|
49
|
+
// `flex`), and this element is its single item — so left to itself it
|
|
50
|
+
// shrink-wraps to its longest line of text, which is why a drawer full of
|
|
51
|
+
// auth blocks came out narrower than the drawer. A CLASS rather than an
|
|
52
|
+
// inline width on purpose: the generated part rule
|
|
53
|
+
// (`[data-block-id="x"] [data-part="…"]`) outranks a class, so a designer
|
|
54
|
+
// who types a width still wins — an inline style would outrank the rule and
|
|
55
|
+
// make that control inert.
|
|
56
|
+
className="w-full"
|
|
57
|
+
data-auth-step-key={stepKey || undefined}
|
|
58
|
+
{...partAttrs('step')}
|
|
59
|
+
style={{
|
|
60
|
+
display: 'flex',
|
|
61
|
+
flexDirection: 'column',
|
|
62
|
+
gap: 'var(--auth-step-gap, 12px)'
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{childBlocks.length === 0 ? (
|
|
66
|
+
<DesignerPlaceholder label="Auth step (drop its content here)" />
|
|
67
|
+
) : (
|
|
68
|
+
renderComposedChildren({
|
|
69
|
+
blocks: childBlocks,
|
|
70
|
+
placeholderId,
|
|
71
|
+
sectionId,
|
|
72
|
+
isDesigner,
|
|
73
|
+
selectedBlockId,
|
|
74
|
+
currentBreakpoint
|
|
75
|
+
})
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export default AuthStepBlock;
|