@akinon/pz-theme 2.0.95-rc.0 → 2.0.95

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +8 -33
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-captcha.test.tsx +261 -0
  4. package/src/__tests__/auth-commerce-events.test.tsx +5 -1
  5. package/src/__tests__/auth-destination.test.tsx +348 -0
  6. package/src/__tests__/auth-error-reading.test.ts +232 -0
  7. package/src/__tests__/auth-flow-scope.test.tsx +162 -0
  8. package/src/__tests__/auth-form-error-block.test.tsx +2 -0
  9. package/src/__tests__/auth-form-success-block.test.tsx +429 -0
  10. package/src/__tests__/auth-form-validation.test.tsx +2 -0
  11. package/src/__tests__/auth-slot-coverage.test.ts +9 -1
  12. package/src/__tests__/auth-step-title.test.tsx +2 -0
  13. package/src/__tests__/block-action-audience.test.ts +154 -0
  14. package/src/__tests__/detached-submit-state.test.tsx +80 -2
  15. package/src/__tests__/forgot-password-composed.test.tsx +2 -0
  16. package/src/__tests__/form-state-composition.test.tsx +484 -0
  17. package/src/__tests__/modal-login-close.test.tsx +366 -0
  18. package/src/__tests__/otp-form-composed.test.tsx +2 -0
  19. package/src/__tests__/password-reset-step.test.tsx +2 -0
  20. package/src/__tests__/register-form-composed.test.tsx +253 -4
  21. package/src/__tests__/registry-parity.test.ts +12 -0
  22. package/src/__tests__/reset-password-composed.test.tsx +385 -0
  23. package/src/auth/auth-captcha.tsx +125 -0
  24. package/src/auth/auth-destination.ts +168 -0
  25. package/src/auth/auth-field-errors.tsx +31 -0
  26. package/src/auth/form-state-context.tsx +170 -0
  27. package/src/auth/form-success-context.tsx +56 -0
  28. package/src/auth/read-auth-error.ts +332 -0
  29. package/src/blocks/__tests__/slider-block.test.tsx +265 -1
  30. package/src/blocks/account-button-block.tsx +25 -0
  31. package/src/blocks/add-to-cart-block.tsx +15 -0
  32. package/src/blocks/auth-field-block.tsx +13 -3
  33. package/src/blocks/auth-flow-block.tsx +45 -3
  34. package/src/blocks/auth-form-block.tsx +337 -54
  35. package/src/blocks/auth-success-block.tsx +85 -0
  36. package/src/blocks/block-renderer-registry.tsx +8 -0
  37. package/src/blocks/button-block.tsx +43 -13
  38. package/src/blocks/captcha-block.tsx +61 -0
  39. package/src/blocks/change-password-form-block.tsx +15 -0
  40. package/src/blocks/forgot-password-block.tsx +132 -9
  41. package/src/blocks/form-state-block.tsx +84 -0
  42. package/src/blocks/newsletter-form-block.tsx +16 -0
  43. package/src/blocks/otp-form-block.tsx +161 -67
  44. package/src/blocks/profile-form-block.tsx +13 -0
  45. package/src/blocks/register-form-block.tsx +362 -110
  46. package/src/blocks/reset-password-block.tsx +717 -0
  47. package/src/blocks/slider-block.tsx +170 -4
  48. package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
  49. package/src/chrome/placeholder-slugs.ts +3 -0
  50. package/src/chrome/route-slug.ts +11 -0
  51. package/src/index.ts +10 -0
  52. package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
  53. package/src/overlays/__tests__/modal-registry.test.ts +5 -1
  54. package/src/overlays/modal-chrome-css.ts +8 -1
  55. package/src/overlays/modal-host-context.tsx +66 -0
  56. package/src/overlays/modal-preview-host.tsx +7 -2
  57. package/src/overlays/modal-registry.ts +38 -1
  58. package/src/overlays/overlay-loading.tsx +144 -0
  59. package/src/overlays/overlay-surface.tsx +23 -0
  60. package/src/overlays/overlay-tokens.ts +12 -0
  61. package/src/overlays/theme-modal-mount.tsx +85 -6
  62. package/src/utils/visibility-rules.ts +114 -0
@@ -0,0 +1,717 @@
1
+ 'use client';
2
+
3
+ import React, { useContext, useState } from 'react';
4
+ import { useParams, useSearchParams } from 'next/navigation';
5
+ import {
6
+ usePasswordResetMutation,
7
+ useGetValidatePasswordResetTokenQuery
8
+ } from '@akinon/next/data/client/account';
9
+ import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
10
+ import { readToggle } from './product-block-utils';
11
+ import {
12
+ AuthFieldErrors,
13
+ AuthFieldErrorsProvider,
14
+ clearAuthFieldError,
15
+ collectAuthFieldErrors,
16
+ focusFirstAuthFieldError
17
+ } from '../auth/auth-field-errors';
18
+ import {
19
+ authFormMessage,
20
+ readCommerceError,
21
+ splitByKnownFields
22
+ } from '../auth/read-auth-error';
23
+ import {
24
+ AuthFormErrorProvider,
25
+ containsAuthErrorBlock
26
+ } from '../auth/form-error-context';
27
+ import {
28
+ AuthFormSuccessProvider,
29
+ containsAuthSuccessBlock
30
+ } from '../auth/form-success-context';
31
+ import {
32
+ AuthFormStateProvider,
33
+ AuthFormStateValue,
34
+ containsFormStateBlock,
35
+ showsInFormState
36
+ } from '../auth/form-state-context';
37
+ import {
38
+ clearFormSubmitState,
39
+ publishFormSubmitState
40
+ } from '../auth/form-submit-bus';
41
+ import {
42
+ AuthFlowIdContext,
43
+ resolveAuthStepTarget,
44
+ setAuthStep
45
+ } from '../auth/auth-flow-bus';
46
+ import { renderComposedChildren } from './render-composed-children';
47
+ import { useHostModalCloser } from '../overlays/modal-host-context';
48
+ import { BlockRendererProps } from './block-renderer-registry';
49
+
50
+ /**
51
+ * The two names this form submits under, and therefore the only two a
52
+ * field-level refusal can be hung on.
53
+ */
54
+ const RESET_PASSWORD_FIELD_NAMES = ['password', 'repeatPassword'] as const;
55
+
56
+ /**
57
+ * What commerce calls the two boxes, and what this form calls them.
58
+ *
59
+ * The mutation posts `new_password1` / `new_password2` (see
60
+ * `account.passwordReset`), so a refusal comes back under those names — which
61
+ * no control on screen submits under. Without the map every "this password is
62
+ * too common" would be lifted to the form-level slot as an orphan instead of
63
+ * landing under the box it is about.
64
+ */
65
+ const SERVER_FIELD_ALIASES: Record<string, string> = {
66
+ new_password1: 'password',
67
+ new_password2: 'repeatPassword'
68
+ };
69
+
70
+ const RESET_PREVIEW_STATES = [
71
+ 'form',
72
+ 'success',
73
+ 'expired',
74
+ 'checking'
75
+ ] as const;
76
+
77
+ type ResetPreviewState = (typeof RESET_PREVIEW_STATES)[number];
78
+
79
+ const TOKEN_SOURCES = ['path', 'query'] as const;
80
+
81
+ /** The token, as the reset mail's link carries it. */
82
+ const readToken = (
83
+ source: (typeof TOKEN_SOURCES)[number],
84
+ params: Record<string, string | string[] | undefined>,
85
+ search: URLSearchParams | null,
86
+ queryKey: string
87
+ ): string => {
88
+ if (source === 'query') return (search?.get(queryKey) || '').trim();
89
+
90
+ /**
91
+ * A catch-all route segment, joined back into the token commerce issued:
92
+ * pz's own route is `/users/reset/[[...id]]`, and the token contains
93
+ * slashes, so the segments have to be put back together. The three names are
94
+ * the ones brands actually use for that segment; a brand whose segment is
95
+ * named otherwise points the block at the query instead.
96
+ */
97
+ const raw = params.id ?? params.token ?? params.slug;
98
+
99
+ return (Array.isArray(raw) ? raw.join('/') : raw || '').trim();
100
+ };
101
+
102
+ /**
103
+ * Functional auth block: the screen the password-reset MAIL lands on — the two
104
+ * new-password boxes, the link's own validity, and what the shopper sees when
105
+ * it has expired or when the password has been changed.
106
+ *
107
+ * The other half of the reset journey was the half the editor could not see.
108
+ * `forgot-password` composes the request, and then handed the shopper to a page
109
+ * every brand hand-wrote: the same two inputs, the same four states, restyled
110
+ * per brand and drifting from the drawer they started in. This block is that
111
+ * page, composable the same way every other auth form is.
112
+ *
113
+ * Composable: with children the form draws THEM — an `auth-field` per box, the
114
+ * message a Form Error block, the CTA an ordinary Button — and reads the values
115
+ * back out of `FormData` on submit. Left empty it draws its own two rows, so a
116
+ * brand that only wants the screen gets it without composing anything.
117
+ *
118
+ * The three states that are not the form are authored copy, and each can hand
119
+ * the journey to a STEP instead: an expired link belongs back at the
120
+ * `forgot-password` step (a fresh mail is the only way forward), and a changed
121
+ * password belongs wherever the brand wants the shopper next. That is the same
122
+ * handoff `forgot-password` and `register-form` already use, and it is why
123
+ * those states need no blocks of their own.
124
+ *
125
+ * Properties: submitLabel, busyLabel, showLabels, passwordLabel,
126
+ * passwordPlaceholder, repeatPasswordLabel, repeatPasswordPlaceholder,
127
+ * requiredMessage, invalidMessage, mismatchMessage, errorLabel, successLabel,
128
+ * successStepTarget, expiredLabel, expiredStepTarget, checkingLabel,
129
+ * backLabel, backStepTarget, tokenSource, tokenQueryKey, previewState,
130
+ * formHtmlId, showSubmit.
131
+ * Parts: form, field, label, input, error, submit, success, expired, checking,
132
+ * back.
133
+ */
134
+ const ResetPasswordBlock = ({
135
+ block,
136
+ placeholderId,
137
+ sectionId,
138
+ selectedBlockId,
139
+ currentBreakpoint = 'desktop',
140
+ isDesigner = false
141
+ }: BlockRendererProps) => {
142
+ const prop = (key: string, fallback: string) =>
143
+ String(
144
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
145
+ );
146
+
147
+ /**
148
+ * Whether finishing closes the modal this block is inside. OFF by default
149
+ * here, because this block's success state is something the shopper is meant
150
+ * to read — see `useHostModalCloser`.
151
+ */
152
+ const closeHostModalOnSuccess = readToggle(
153
+ block.properties?.closeHostModalOnSuccess,
154
+ currentBreakpoint,
155
+ false
156
+ );
157
+ const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
158
+
159
+
160
+ const submitLabel = prop('submitLabel', 'Save new password');
161
+ const busyLabel = prop('busyLabel', '');
162
+ const showLabels = readToggle(
163
+ block.properties?.showLabels,
164
+ currentBreakpoint,
165
+ true
166
+ );
167
+ const passwordLabel = prop('passwordLabel', 'New password');
168
+ const passwordPlaceholder = prop('passwordPlaceholder', '');
169
+ const repeatPasswordLabel = prop('repeatPasswordLabel', 'New password again');
170
+ const repeatPasswordPlaceholder = prop('repeatPasswordPlaceholder', '');
171
+
172
+ const requiredMessage = prop('requiredMessage', 'This field is required.');
173
+ const invalidMessage = prop('invalidMessage', 'Please check this field.');
174
+ /**
175
+ * The one rule this form checks that no `required` can express, and the
176
+ * reason it is checked at all: commerce answers a mismatch with a refusal
177
+ * the shopper has to read at the bottom of a round trip, when the browser
178
+ * could have said it before the request left.
179
+ */
180
+ const mismatchMessage = prop(
181
+ 'mismatchMessage',
182
+ 'The two passwords do not match.'
183
+ );
184
+ const errorLabel = prop(
185
+ 'errorLabel',
186
+ 'Could not set the new password. Please try again.'
187
+ );
188
+ const successLabel = prop('successLabel', 'Your password has been changed.');
189
+ const expiredLabel = prop(
190
+ 'expiredLabel',
191
+ 'This link has expired or has already been used.'
192
+ );
193
+ /**
194
+ * Empty on purpose: a link that is still being checked is a fraction of a
195
+ * second on a warm connection, and a sentence that appears and vanishes is
196
+ * worse than nothing. A brand that wants one types it.
197
+ */
198
+ const checkingLabel = prop('checkingLabel', '');
199
+ const backLabel = prop('backLabel', 'Back to log in');
200
+ const backStepTarget = prop('backStepTarget', '').trim();
201
+ const successStepTarget = prop('successStepTarget', '').trim();
202
+ /**
203
+ * Where an expired link sends the shopper. A step holding a
204
+ * `forgot-password` block is the only useful destination — a new mail is the
205
+ * only way forward — which is exactly what a flow can express and this block
206
+ * cannot.
207
+ */
208
+ const expiredStepTarget = prop('expiredStepTarget', '').trim();
209
+
210
+ const tokenSource = resolveOption<(typeof TOKEN_SOURCES)[number]>(
211
+ block.properties?.tokenSource,
212
+ currentBreakpoint,
213
+ 'path',
214
+ TOKEN_SOURCES as unknown as (typeof TOKEN_SOURCES)[number][]
215
+ );
216
+ const tokenQueryKey = prop('tokenQueryKey', 'token').trim() || 'token';
217
+
218
+ /**
219
+ * Which state the canvas shows. Three of the four are unreachable in the
220
+ * editor — they need a real token, a real refusal or a real success — so
221
+ * without this a designer could style the form and nothing else.
222
+ */
223
+ const previewState = resolveOption<ResetPreviewState>(
224
+ block.properties?.previewState,
225
+ currentBreakpoint,
226
+ 'form',
227
+ RESET_PREVIEW_STATES as unknown as ResetPreviewState[]
228
+ );
229
+
230
+ const formHtmlId = prop('formHtmlId', '').trim();
231
+ const showSubmit = readToggle(
232
+ block.properties?.showSubmit,
233
+ currentBreakpoint,
234
+ true
235
+ );
236
+
237
+ const contextFlowId = useContext(AuthFlowIdContext);
238
+ const backStep = resolveAuthStepTarget(backStepTarget, contextFlowId);
239
+ const successStep = resolveAuthStepTarget(successStepTarget, contextFlowId);
240
+ const expiredStep = resolveAuthStepTarget(expiredStepTarget, contextFlowId);
241
+
242
+ const params = useParams() as Record<string, string | string[] | undefined>;
243
+ const search = useSearchParams();
244
+ const token = readToken(tokenSource, params || {}, search, tokenQueryKey);
245
+
246
+ const childBlocks = (block.blocks || [])
247
+ .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
248
+ .sort((a, b) => (a.order || 0) - (b.order || 0));
249
+ const isComposed = childBlocks.length > 0;
250
+ /**
251
+ * Whether the composition draws the confirmation itself, with a Form Success
252
+ * block — in which case the form is not replaced by the built-in copy, since
253
+ * a block inside a form that has been swapped out is unmounted and draws
254
+ * nothing. See `auth/form-success-context.tsx`.
255
+ */
256
+ /**
257
+ * Which of this block's four screens the COMPOSITION draws itself, in a Form
258
+ * State container. Each one the composition owns is a built-in replacement
259
+ * that stands down — and it has to, since those replacements swap out the
260
+ * whole composition and would unmount the very container meant to draw the
261
+ * screen.
262
+ */
263
+ const composedScreen = (want: string) =>
264
+ isComposed && containsFormStateBlock(childBlocks, want, currentBreakpoint);
265
+ const successScreenIsComposed = composedScreen('success');
266
+ const successIsAuthored =
267
+ successScreenIsComposed ||
268
+ (isComposed && containsAuthSuccessBlock(childBlocks));
269
+
270
+ const [values, setValues] = useState({ password: '', repeatPassword: '' });
271
+ const [error, setError] = useState('');
272
+ const [fieldErrors, setFieldErrors] = useState<AuthFieldErrors>({});
273
+
274
+ /**
275
+ * The link's own validity, asked before the shopper is invited to type
276
+ * anything. Skipped in the canvas — the designer has no token, and asking
277
+ * commerce about one would answer "expired" for every composition.
278
+ */
279
+ const {
280
+ data: validation,
281
+ isLoading: isValidating,
282
+ isError: validationFailed
283
+ } = useGetValidatePasswordResetTokenQuery(token, {
284
+ skip: !token || isDesigner
285
+ });
286
+
287
+ const [passwordReset, { isLoading, isSuccess }] = usePasswordResetMutation();
288
+
289
+ const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
290
+ const target = event.target as { name?: string } | null;
291
+
292
+ setFieldErrors((current) =>
293
+ clearAuthFieldError(current, target?.name || '')
294
+ );
295
+ };
296
+
297
+ /**
298
+ * A detached CTA (a Button carrying this form's id) is outside this subtree,
299
+ * so the busy state travels through the module bus rather than through
300
+ * props. Same contract as the other auth forms.
301
+ */
302
+ React.useEffect(() => {
303
+ if (!formHtmlId) return undefined;
304
+
305
+ publishFormSubmitState(formHtmlId, isLoading);
306
+
307
+ return () => clearFormSubmitState(formHtmlId);
308
+ }, [formHtmlId, isLoading]);
309
+
310
+ const submit = async (event: React.FormEvent<HTMLFormElement>) => {
311
+ event.preventDefault();
312
+ setError('');
313
+
314
+ // Submitting inside the designer would spend the shopper's real token.
315
+ // `isSuccess` closes the one path that survives the success state: the
316
+ // composed form stays on screen when it draws its own confirmation, so a
317
+ // DETACHED CTA — a Button carrying this form's id, outside this subtree and
318
+ // therefore outside the branch that drops the built-in one — could still
319
+ // submit a token that has already been spent.
320
+ if (isDesigner || isLoading || isSuccess) return;
321
+
322
+ // Held onto because `currentTarget` is only the form for as long as the
323
+ // handler runs, and the refusal is read after an await.
324
+ const formElement = event.currentTarget;
325
+
326
+ const { errors: nextFieldErrors, firstInvalid } = collectAuthFieldErrors(
327
+ formElement,
328
+ { required: requiredMessage, invalid: invalidMessage }
329
+ );
330
+
331
+ const entries = new FormData(formElement);
332
+ const typed = isComposed
333
+ ? {
334
+ password: String(entries.get('password') ?? ''),
335
+ repeatPassword: String(entries.get('repeatPassword') ?? '')
336
+ }
337
+ : values;
338
+
339
+ /**
340
+ * The mismatch, checked only once both boxes are filled: telling a shopper
341
+ * the passwords do not match while they are still typing the second one is
342
+ * a message about work in progress.
343
+ */
344
+ const mismatched =
345
+ Boolean(typed.password) &&
346
+ Boolean(typed.repeatPassword) &&
347
+ typed.password !== typed.repeatPassword;
348
+
349
+ const withMismatch = mismatched
350
+ ? { ...nextFieldErrors, repeatPassword: mismatchMessage }
351
+ : nextFieldErrors;
352
+
353
+ setFieldErrors(withMismatch);
354
+
355
+ if (firstInvalid) {
356
+ firstInvalid.focus();
357
+
358
+ return;
359
+ }
360
+
361
+ if (mismatched) {
362
+ focusFirstAuthFieldError(formElement, withMismatch);
363
+
364
+ return;
365
+ }
366
+
367
+ try {
368
+ await passwordReset({
369
+ password: typed.password,
370
+ repeatPassword: typed.repeatPassword,
371
+ slug: token
372
+ }).unwrap();
373
+
374
+ if (successStep) setAuthStep(successStep.flowId, successStep.stepKey);
375
+
376
+ // Same rule as the forgot-password form: off by default, because
377
+ // "your password has been changed" is what the shopper came for.
378
+ closeHostModal();
379
+ } catch (rejection) {
380
+ /**
381
+ * What commerce actually refused — a password policy, a reused password,
382
+ * a token that expired between the check and the submit — routed to the
383
+ * box it is about through the alias map above.
384
+ */
385
+ const reading = readCommerceError((rejection as { data?: unknown })?.data);
386
+ const aliased = Object.entries(reading.fieldMessages).reduce(
387
+ (acc, [name, message]) => {
388
+ acc[SERVER_FIELD_ALIASES[name] || name] = message;
389
+
390
+ return acc;
391
+ },
392
+ {} as Record<string, string>
393
+ );
394
+
395
+ const attributed = splitByKnownFields(
396
+ { ...reading, fieldMessages: aliased },
397
+ formElement,
398
+ RESET_PASSWORD_FIELD_NAMES
399
+ );
400
+
401
+ setFieldErrors((current) => ({ ...current, ...attributed.fieldMessages }));
402
+ focusFirstAuthFieldError(formElement, attributed.fieldMessages);
403
+ setError(authFormMessage(attributed, errorLabel));
404
+ }
405
+ };
406
+
407
+ const stepControl = (
408
+ step: { flowId: string; stepKey: string } | null,
409
+ label: string,
410
+ marker: string
411
+ ) =>
412
+ step ? (
413
+ <button
414
+ type="button"
415
+ {...{ [marker]: '' }}
416
+ {...partAttrs('back')}
417
+ onClick={() => {
418
+ // The canvas has its own step switcher; a click here must not move
419
+ // the flow under the designer's feet.
420
+ if (isDesigner) return;
421
+
422
+ setAuthStep(step.flowId, step.stepKey);
423
+ }}
424
+ style={{
425
+ alignSelf: 'flex-start',
426
+ padding: 0,
427
+ margin: 0,
428
+ border: 'none',
429
+ background: 'transparent',
430
+ color: 'inherit',
431
+ font: 'inherit',
432
+ textDecoration: 'underline',
433
+ cursor: 'pointer'
434
+ }}
435
+ >
436
+ {label}
437
+ </button>
438
+ ) : null;
439
+
440
+ const shows = (state: ResetPreviewState) =>
441
+ isDesigner ? previewState === state : false;
442
+
443
+ /** Changed — for real on a storefront, on the preview select in the canvas. */
444
+ const inSuccessState =
445
+ (!isDesigner && isSuccess && !successStep) || shows('success');
446
+
447
+ // Changed. The form is replaced rather than left on screen: the token is
448
+ // spent, so submitting it again could only fail. A composition that placed a
449
+ // Form Success block keeps its own layout instead, and loses the submit for
450
+ // the same reason the replacement existed — see below.
451
+ if (inSuccessState && !successIsAuthored) {
452
+ return (
453
+ <div
454
+ data-reset-password=""
455
+ className="w-full"
456
+ data-reset-password-state="success"
457
+ {...partAttrs('success')}
458
+ role="status"
459
+ >
460
+ {successLabel}
461
+ {stepControl(backStep, backLabel, 'data-reset-password-back')}
462
+ </div>
463
+ );
464
+ }
465
+
466
+ // No token at all is the same dead end as a spent one — the shopper reached
467
+ // this screen without a link — so it is answered the same way.
468
+ const linkIsDead =
469
+ !isDesigner &&
470
+ !isValidating &&
471
+ (!token || validationFailed || validation?.validlink === false);
472
+
473
+ if ((linkIsDead || shows('expired')) && !composedScreen('expired')) {
474
+ return (
475
+ <div
476
+ data-reset-password=""
477
+ className="w-full"
478
+ data-reset-password-state="expired"
479
+ {...partAttrs('expired')}
480
+ role="alert"
481
+ >
482
+ {expiredLabel}
483
+ {stepControl(expiredStep, backLabel, 'data-reset-password-expired-back')}
484
+ </div>
485
+ );
486
+ }
487
+
488
+ if (
489
+ ((!isDesigner && isValidating) || shows('checking')) &&
490
+ !composedScreen('checking')
491
+ ) {
492
+ // Nothing at all unless the brand authored a sentence: see `checkingLabel`.
493
+ if (!checkingLabel) return null;
494
+
495
+ return (
496
+ <div
497
+ data-reset-password=""
498
+ className="w-full"
499
+ data-reset-password-state="checking"
500
+ {...partAttrs('checking')}
501
+ role="status"
502
+ aria-busy
503
+ >
504
+ {checkingLabel}
505
+ </div>
506
+ );
507
+ }
508
+
509
+ /**
510
+ * What the composition's Form State containers read. The screen is exclusive
511
+ * — the shopper is on one of the four — while `busy` and `hasError` sit
512
+ * beside it, so a refusal does not take the two boxes away with it.
513
+ */
514
+ const formStateValue: AuthFormStateValue = {
515
+ screen: inSuccessState
516
+ ? 'success'
517
+ : linkIsDead || shows('expired')
518
+ ? 'expired'
519
+ : (!isDesigner && isValidating) || shows('checking')
520
+ ? 'checking'
521
+ : 'form',
522
+ busy: isLoading,
523
+ hasError: Boolean(error)
524
+ };
525
+
526
+ const formStyle: React.CSSProperties = {
527
+ display: 'flex',
528
+ flexDirection: 'column',
529
+ gap: 'var(--reset-password-gap, 12px)'
530
+ };
531
+
532
+ const submitButton = showSubmit ? (
533
+ <button
534
+ type="submit"
535
+ data-reset-password-submit=""
536
+ {...partAttrs('submit', { loading: isLoading, disabled: isLoading })}
537
+ disabled={isLoading}
538
+ aria-busy={isLoading}
539
+ style={{
540
+ padding: '12px 20px',
541
+ background: 'var(--reset-password-cta-bg, #111827)',
542
+ color: 'var(--reset-password-cta-color, #ffffff)',
543
+ border: 'none',
544
+ cursor: isLoading ? 'not-allowed' : 'pointer',
545
+ opacity: isLoading ? 0.7 : 1
546
+ }}
547
+ >
548
+ {isLoading && busyLabel ? busyLabel : submitLabel}
549
+ </button>
550
+ ) : null;
551
+
552
+ if (isComposed) {
553
+ const errorIsAuthored = containsAuthErrorBlock(childBlocks);
554
+
555
+ return (
556
+ <AuthFormErrorProvider value={error}>
557
+ {/* The confirmation, for the block the composition placed for it. Empty
558
+ until the password has actually been changed. */}
559
+ <AuthFormSuccessProvider value={inSuccessState ? successLabel : ''}>
560
+ <AuthFormStateProvider value={formStateValue}>
561
+ <AuthFieldErrorsProvider value={fieldErrors}>
562
+ <form
563
+ id={formHtmlId || undefined}
564
+ data-reset-password=""
565
+ data-reset-password-composed=""
566
+ className="w-full"
567
+ data-reset-password-state={
568
+ inSuccessState ? 'success' : isLoading ? 'busy' : 'idle'
569
+ }
570
+ {...partAttrs('form')}
571
+ onSubmit={submit}
572
+ noValidate
573
+ onInput={handleInput}
574
+ onChange={handleInput}
575
+ style={formStyle}
576
+ >
577
+ {renderComposedChildren({
578
+ // Only the containers this screen calls for; a child that is not
579
+ // a container is shared chrome and shows on all four.
580
+ blocks: childBlocks.filter((childBlock) =>
581
+ showsInFormState({
582
+ block: childBlock,
583
+ state: formStateValue,
584
+ isDesigner,
585
+ selectedBlockId,
586
+ currentBreakpoint
587
+ })
588
+ ),
589
+ placeholderId,
590
+ sectionId,
591
+ isDesigner,
592
+ selectedBlockId,
593
+ currentBreakpoint
594
+ })}
595
+
596
+ {/* The fallback for a composition that has not placed a Form Error
597
+ block; see `auth/form-error-context.tsx`. */}
598
+ {error && !errorIsAuthored && (
599
+ <div
600
+ data-reset-password-error=""
601
+ {...partAttrs('error')}
602
+ role="alert"
603
+ >
604
+ {error}
605
+ </div>
606
+ )}
607
+
608
+ {/* Only on the form itself: the token is spent once the password
609
+ is changed and there is nothing to submit on a dead or
610
+ unchecked link, so a button there could only fail. */}
611
+ {formStateValue.screen === 'form' && submitButton}
612
+
613
+ {/* The authored way back. Not when a container owns the screen —
614
+ the way onward is then part of the composition. */}
615
+ {inSuccessState &&
616
+ !successScreenIsComposed &&
617
+ stepControl(backStep, backLabel, 'data-reset-password-back')}
618
+ </form>
619
+ </AuthFieldErrorsProvider>
620
+ </AuthFormStateProvider>
621
+ </AuthFormSuccessProvider>
622
+ </AuthFormErrorProvider>
623
+ );
624
+ }
625
+
626
+ const row = (
627
+ name: 'password' | 'repeatPassword',
628
+ label: string,
629
+ placeholder: string,
630
+ autoComplete: string
631
+ ) => (
632
+ <label
633
+ data-reset-password-field={name}
634
+ {...partAttrs('field')}
635
+ style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
636
+ >
637
+ {/* Hiding the caption must not cost the input its accessible name, so the
638
+ label text moves onto aria-label instead of disappearing. */}
639
+ {showLabels && <span {...partAttrs('label')}>{label}</span>}
640
+ <input
641
+ type="password"
642
+ name={name}
643
+ value={values[name]}
644
+ onChange={(event) =>
645
+ setValues((current) => ({ ...current, [name]: event.target.value }))
646
+ }
647
+ // `new-password` on both: this screen is where a password manager
648
+ // should offer to GENERATE one and save what it generated, which it
649
+ // will not do for a field that claims to be the current password.
650
+ autoComplete={autoComplete}
651
+ placeholder={placeholder || undefined}
652
+ aria-label={showLabels ? undefined : label}
653
+ aria-invalid={fieldErrors[name] ? true : undefined}
654
+ required
655
+ {...partAttrs('input')}
656
+ style={{
657
+ width: '100%',
658
+ border: `1px solid ${
659
+ fieldErrors[name]
660
+ ? 'var(--reset-password-border-invalid, #dc2626)'
661
+ : 'var(--reset-password-border, #d1d5db)'
662
+ }`,
663
+ background: 'transparent',
664
+ color: 'inherit',
665
+ padding: '10px 12px',
666
+ outline: 'none'
667
+ }}
668
+ />
669
+ {fieldErrors[name] && (
670
+ <span
671
+ data-reset-password-field-error={name}
672
+ {...partAttrs('error')}
673
+ role="alert"
674
+ style={{
675
+ color: 'var(--reset-password-error-color, #dc2626)',
676
+ fontSize: '0.875em'
677
+ }}
678
+ >
679
+ {fieldErrors[name]}
680
+ </span>
681
+ )}
682
+ </label>
683
+ );
684
+
685
+ return (
686
+ <form
687
+ id={formHtmlId || undefined}
688
+ data-reset-password=""
689
+ className="w-full"
690
+ data-reset-password-state={isLoading ? 'busy' : 'idle'}
691
+ {...partAttrs('form')}
692
+ onSubmit={submit}
693
+ noValidate
694
+ onInput={handleInput}
695
+ onChange={handleInput}
696
+ style={formStyle}
697
+ >
698
+ {row('password', passwordLabel, passwordPlaceholder, 'new-password')}
699
+ {row(
700
+ 'repeatPassword',
701
+ repeatPasswordLabel,
702
+ repeatPasswordPlaceholder,
703
+ 'new-password'
704
+ )}
705
+
706
+ {error && (
707
+ <div data-reset-password-error="" {...partAttrs('error')} role="alert">
708
+ {error}
709
+ </div>
710
+ )}
711
+
712
+ {submitButton}
713
+ </form>
714
+ );
715
+ };
716
+
717
+ export default ResetPasswordBlock;