@akinon/pz-theme 2.0.95-rc.0 → 2.0.95-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/package.json +4 -4
- package/src/__tests__/auth-captcha.test.tsx +261 -0
- package/src/__tests__/auth-commerce-events.test.tsx +5 -1
- package/src/__tests__/auth-destination.test.tsx +348 -0
- package/src/__tests__/auth-error-reading.test.ts +232 -0
- package/src/__tests__/auth-flow-scope.test.tsx +162 -0
- package/src/__tests__/auth-form-error-block.test.tsx +2 -0
- package/src/__tests__/auth-form-success-block.test.tsx +429 -0
- package/src/__tests__/auth-form-validation.test.tsx +2 -0
- package/src/__tests__/auth-slot-coverage.test.ts +9 -1
- package/src/__tests__/auth-step-title.test.tsx +2 -0
- package/src/__tests__/block-action-audience.test.ts +154 -0
- package/src/__tests__/detached-submit-state.test.tsx +80 -2
- package/src/__tests__/forgot-password-composed.test.tsx +2 -0
- package/src/__tests__/form-state-composition.test.tsx +484 -0
- package/src/__tests__/modal-login-close.test.tsx +366 -0
- package/src/__tests__/otp-form-composed.test.tsx +2 -0
- package/src/__tests__/password-reset-step.test.tsx +2 -0
- package/src/__tests__/register-form-composed.test.tsx +253 -4
- package/src/__tests__/registry-parity.test.ts +12 -0
- package/src/__tests__/reset-password-composed.test.tsx +385 -0
- package/src/auth/auth-captcha.tsx +125 -0
- package/src/auth/auth-destination.ts +168 -0
- package/src/auth/auth-field-errors.tsx +31 -0
- package/src/auth/form-state-context.tsx +170 -0
- package/src/auth/form-success-context.tsx +56 -0
- package/src/auth/read-auth-error.ts +332 -0
- package/src/blocks/account-button-block.tsx +25 -0
- package/src/blocks/add-to-cart-block.tsx +15 -0
- package/src/blocks/auth-field-block.tsx +13 -3
- package/src/blocks/auth-flow-block.tsx +45 -3
- package/src/blocks/auth-form-block.tsx +337 -54
- package/src/blocks/auth-success-block.tsx +85 -0
- package/src/blocks/block-renderer-registry.tsx +8 -0
- package/src/blocks/button-block.tsx +43 -13
- package/src/blocks/captcha-block.tsx +61 -0
- package/src/blocks/change-password-form-block.tsx +15 -0
- package/src/blocks/forgot-password-block.tsx +132 -9
- package/src/blocks/form-state-block.tsx +84 -0
- package/src/blocks/newsletter-form-block.tsx +16 -0
- package/src/blocks/otp-form-block.tsx +161 -67
- package/src/blocks/profile-form-block.tsx +13 -0
- package/src/blocks/register-form-block.tsx +362 -110
- package/src/blocks/reset-password-block.tsx +717 -0
- package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
- package/src/chrome/placeholder-slugs.ts +3 -0
- package/src/chrome/route-slug.ts +11 -0
- package/src/index.ts +10 -0
- package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
- package/src/overlays/__tests__/modal-registry.test.ts +5 -1
- package/src/overlays/modal-chrome-css.ts +8 -1
- package/src/overlays/modal-host-context.tsx +66 -0
- package/src/overlays/modal-preview-host.tsx +7 -2
- package/src/overlays/modal-registry.ts +38 -1
- package/src/overlays/overlay-loading.tsx +144 -0
- package/src/overlays/overlay-surface.tsx +23 -0
- package/src/overlays/overlay-tokens.ts +12 -0
- package/src/overlays/theme-modal-mount.tsx +85 -6
- package/src/utils/visibility-rules.ts +114 -0
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React, { useContext, useEffect, useRef, useState } from 'react';
|
|
4
|
-
import { useRouter } from 'next/navigation';
|
|
4
|
+
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
5
5
|
import { signIn } from 'next-auth/react';
|
|
6
6
|
import { useLocalization } from '@akinon/next/hooks';
|
|
7
7
|
import { useOtpLoginMutation } from '@akinon/next/data/client/user';
|
|
8
8
|
import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
|
|
9
9
|
import { readToggle } from './product-block-utils';
|
|
10
10
|
import { closeAllModals } from '../overlays/overlay-bus';
|
|
11
|
+
import {
|
|
12
|
+
useHostModalCloser,
|
|
13
|
+
useModalHostId
|
|
14
|
+
} from '../overlays/modal-host-context';
|
|
11
15
|
import {
|
|
12
16
|
AuthFlowIdContext,
|
|
13
17
|
clearPendingOtp,
|
|
@@ -25,51 +29,36 @@ import {
|
|
|
25
29
|
AuthFormErrorProvider,
|
|
26
30
|
containsAuthErrorBlock
|
|
27
31
|
} from '../auth/form-error-context';
|
|
32
|
+
import {
|
|
33
|
+
AuthFormStateProvider,
|
|
34
|
+
AuthFormStateValue,
|
|
35
|
+
showsInFormState
|
|
36
|
+
} from '../auth/form-state-context';
|
|
28
37
|
import {
|
|
29
38
|
clearFormSubmitState,
|
|
30
39
|
publishFormSubmitState
|
|
31
40
|
} from '../auth/form-submit-bus';
|
|
32
41
|
import { OtpFormApi, OtpFormProvider } from '../auth/otp-form-context';
|
|
33
42
|
import { renderComposedChildren } from './render-composed-children';
|
|
34
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
authFormMessage,
|
|
45
|
+
readAuthError,
|
|
46
|
+
splitByKnownFields
|
|
47
|
+
} from '../auth/read-auth-error';
|
|
48
|
+
import {
|
|
49
|
+
AUTH_AFTER_SIGN_IN_MODES,
|
|
50
|
+
AuthAfterSignIn,
|
|
51
|
+
resolveAuthDestination
|
|
52
|
+
} from '../auth/auth-destination';
|
|
35
53
|
|
|
36
54
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
55
|
+
* The two names an OTP journey submits under, and therefore the only two a
|
|
56
|
+
* field-level refusal can be hung on. The verify handler has no form element
|
|
57
|
+
* to measure — its CTA may be a detached Button outside the form — so the set
|
|
58
|
+
* is named rather than read off the DOM.
|
|
41
59
|
*/
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
const parsed = JSON.parse(error);
|
|
47
|
-
if (!Array.isArray(parsed)) return String(error);
|
|
48
|
-
|
|
49
|
-
const messages: string[] = [];
|
|
50
|
-
|
|
51
|
-
parsed.forEach((entry) => {
|
|
52
|
-
const data = (entry as { data?: unknown })?.data;
|
|
53
|
-
|
|
54
|
-
if (Array.isArray(data)) {
|
|
55
|
-
data.forEach((item) => {
|
|
56
|
-
if (typeof item === 'string') {
|
|
57
|
-
messages.push(item);
|
|
58
|
-
} else if (item && typeof item === 'object') {
|
|
59
|
-
const value = (item as { value?: unknown }).value;
|
|
60
|
-
if (typeof value === 'string') messages.push(value);
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
} else if (typeof data === 'string') {
|
|
64
|
-
messages.push(data);
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
return messages.join(' ') || String(error);
|
|
69
|
-
} catch {
|
|
70
|
-
return String(error);
|
|
71
|
-
}
|
|
72
|
-
};
|
|
60
|
+
const OTP_FIELD_NAMES = ['code', 'phone'] as const;
|
|
61
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
73
62
|
|
|
74
63
|
/**
|
|
75
64
|
* Where the serialised AuthError[] actually lives depends on the next-auth
|
|
@@ -82,28 +71,6 @@ const signInErrorDetail = (
|
|
|
82
71
|
response: { error?: string | null; code?: string | null } | null | undefined
|
|
83
72
|
): string | null | undefined => response?.code || response?.error;
|
|
84
73
|
|
|
85
|
-
/**
|
|
86
|
-
* Whether commerce answered with another SMS-verification challenge — which is
|
|
87
|
-
* what a RESEND of a parked registration is supposed to get back, as opposed to
|
|
88
|
-
* the refusals (a rate limit, an expired attempt) that must reach the shopper.
|
|
89
|
-
* Kept local for the same reason `readAuthError` is: exporting register-form's
|
|
90
|
-
* copy would change a shipped module's public surface.
|
|
91
|
-
*/
|
|
92
|
-
const hasOtpChallenge = (error: string | null | undefined): boolean => {
|
|
93
|
-
if (!error) return false;
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
const parsed = JSON.parse(error);
|
|
97
|
-
|
|
98
|
-
return (
|
|
99
|
-
Array.isArray(parsed) &&
|
|
100
|
-
parsed.some((entry) => (entry as { type?: unknown })?.type === 'otp')
|
|
101
|
-
);
|
|
102
|
-
} catch {
|
|
103
|
-
return false;
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
|
|
107
74
|
/**
|
|
108
75
|
* The OTP endpoint answers a successful send with 2xx and an EMPTY body, which
|
|
109
76
|
* RTK Query rejects as a parse failure. Brands treat that rejection as the
|
|
@@ -174,6 +141,25 @@ const OtpFormBlock = ({
|
|
|
174
141
|
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
175
142
|
);
|
|
176
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The modal this form is inside, if any. Read before the destination is
|
|
146
|
+
* resolved, because it decides that destination's default: a sign-in inside
|
|
147
|
+
* a drawer is a detour from the page the shopper is on, so `stay` is right
|
|
148
|
+
* there and `fixed` — the historical push — is right on a page.
|
|
149
|
+
*/
|
|
150
|
+
const hostModalId = useModalHostId();
|
|
151
|
+
/**
|
|
152
|
+
* Whether finishing closes the modal this form is inside. On here because a
|
|
153
|
+
* sign-in leaves nothing to read; the forms whose success state IS the point
|
|
154
|
+
* ("check your inbox") default to off. See `useHostModalCloser`.
|
|
155
|
+
*/
|
|
156
|
+
const closeHostModalOnSuccess = readToggle(
|
|
157
|
+
block.properties?.closeHostModalOnSuccess,
|
|
158
|
+
currentBreakpoint,
|
|
159
|
+
true
|
|
160
|
+
);
|
|
161
|
+
const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
|
|
162
|
+
|
|
177
163
|
const collectPhone = readToggle(
|
|
178
164
|
block.properties?.collectPhone,
|
|
179
165
|
currentBreakpoint,
|
|
@@ -231,7 +217,24 @@ const OtpFormBlock = ({
|
|
|
231
217
|
const providerId = prop('providerId', 'default');
|
|
232
218
|
const otpFormType = prop('otpFormType', 'otpLogin');
|
|
233
219
|
const callbackUrl = prop('callbackUrl', '/');
|
|
220
|
+
/**
|
|
221
|
+
* Where a finished verification leaves the shopper. Same three modes as the
|
|
222
|
+
* Auth Form, and the same default — a phone login inside a drawer wants
|
|
223
|
+
* `stay` just as much as an e-mail one does.
|
|
224
|
+
*/
|
|
225
|
+
const afterSignIn = resolveOption<AuthAfterSignIn>(
|
|
226
|
+
block.properties?.afterSignIn,
|
|
227
|
+
currentBreakpoint,
|
|
228
|
+
'fixed',
|
|
229
|
+
AUTH_AFTER_SIGN_IN_MODES as unknown as AuthAfterSignIn[]
|
|
230
|
+
);
|
|
234
231
|
const errorLabel = prop('errorLabel', 'Verification failed.');
|
|
232
|
+
// Named rather than shown as raw JSON — see auth-form-block for the reason
|
|
233
|
+
// this theme cannot answer the challenge yet.
|
|
234
|
+
const captchaLabel = prop(
|
|
235
|
+
'captchaLabel',
|
|
236
|
+
'Please complete the security check and try again.'
|
|
237
|
+
);
|
|
235
238
|
/**
|
|
236
239
|
* Submitting a half-typed code used to be a silent no-op: the built-in verify
|
|
237
240
|
* button is disabled until the last box is filled, so nothing could reach it.
|
|
@@ -248,13 +251,17 @@ const OtpFormBlock = ({
|
|
|
248
251
|
'codeIncompleteMessage',
|
|
249
252
|
'Enter the {length}-digit code we sent you.'
|
|
250
253
|
);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Off now, and the reason it used to be on is what changed: "the commonest
|
|
256
|
+
* home for this block is a drawer" is exactly the case the host-scoped close
|
|
257
|
+
* below handles, without touching any other overlay. Left on, this toggle
|
|
258
|
+
* would take the WIDE branch — every open modal on the page — so a phone
|
|
259
|
+
* login would also close an unrelated mini-basket or quick-view.
|
|
260
|
+
*/
|
|
254
261
|
const closeModalsOnSuccess = readToggle(
|
|
255
262
|
block.properties?.closeModalsOnSuccess,
|
|
256
263
|
currentBreakpoint,
|
|
257
|
-
|
|
264
|
+
false
|
|
258
265
|
);
|
|
259
266
|
// The code phase is unreachable in the canvas without sending a real SMS,
|
|
260
267
|
// so the designer picks the phase to dress — the previewSuccess pattern.
|
|
@@ -266,6 +273,17 @@ const OtpFormBlock = ({
|
|
|
266
273
|
);
|
|
267
274
|
|
|
268
275
|
const router = useRouter();
|
|
276
|
+
const search = useSearchParams();
|
|
277
|
+
const pathname = usePathname();
|
|
278
|
+
const destination = resolveAuthDestination({
|
|
279
|
+
mode: afterSignIn,
|
|
280
|
+
authored: callbackUrl,
|
|
281
|
+
search,
|
|
282
|
+
// A sign-in inside a drawer is a detour: a destination that names nowhere
|
|
283
|
+
// in particular means there is nowhere to go. An authored one still wins.
|
|
284
|
+
inModal: Boolean(hostModalId),
|
|
285
|
+
pathname
|
|
286
|
+
});
|
|
269
287
|
const { locale, defaultLocaleValue } = useLocalization();
|
|
270
288
|
const contextFlowId = useContext(AuthFlowIdContext);
|
|
271
289
|
/**
|
|
@@ -506,14 +524,48 @@ const OtpFormBlock = ({
|
|
|
506
524
|
});
|
|
507
525
|
|
|
508
526
|
if (!response || response.error) {
|
|
509
|
-
|
|
527
|
+
// A wrong or expired code is a FIELD refusal — commerce names `code`
|
|
528
|
+
// — so it belongs under the boxes the shopper just typed into.
|
|
529
|
+
const reading = splitByKnownFields(
|
|
530
|
+
readAuthError(signInErrorDetail(response), { captcha: captchaLabel }),
|
|
531
|
+
null,
|
|
532
|
+
OTP_FIELD_NAMES
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
setFieldErrors((current) => ({ ...current, ...reading.fieldMessages }));
|
|
536
|
+
setError(authFormMessage(reading, errorLabel));
|
|
537
|
+
|
|
510
538
|
return;
|
|
511
539
|
}
|
|
512
540
|
|
|
513
541
|
clearPendingOtp(contextFlowId);
|
|
542
|
+
/**
|
|
543
|
+
* The surface this form finished inside.
|
|
544
|
+
*
|
|
545
|
+
* Closing the modal it is IN is the default because it is never a
|
|
546
|
+
* surprise: a shopper who signed in inside the drawer is done with the
|
|
547
|
+
* drawer. `closeModalsOnSuccess` keeps its own, wider meaning — close
|
|
548
|
+
* EVERY open modal — for a composition that authored it, which is what a
|
|
549
|
+
* form nested two overlays deep needs.
|
|
550
|
+
*
|
|
551
|
+
* The live GANT drawer is why the default had to change: its OTP form has
|
|
552
|
+
* the toggle on and its login and register forms do not, so signing in
|
|
553
|
+
* with an e-mail left the drawer covering the page.
|
|
554
|
+
*/
|
|
555
|
+
/**
|
|
556
|
+
* Both, not either: a composition that ticked the legacy toggle asked
|
|
557
|
+
* for "every open modal", and the wide close is a superset — so the
|
|
558
|
+
* narrow one runs unconditionally and simply finds nothing left to do.
|
|
559
|
+
* Sequencing them the other way round (an `else`) would have left every
|
|
560
|
+
* already-published drawer on the wide branch forever, which is the one
|
|
561
|
+
* behaviour this family exists to stop.
|
|
562
|
+
*/
|
|
514
563
|
if (closeModalsOnSuccess) closeAllModals();
|
|
564
|
+
closeHostModal();
|
|
515
565
|
router.refresh();
|
|
516
|
-
|
|
566
|
+
// Our own resolved path rather than next-auth's echo of it; `null` is
|
|
567
|
+
// `stay`, where the refresh above is the whole navigation.
|
|
568
|
+
if (destination) router.push(destination);
|
|
517
569
|
} catch {
|
|
518
570
|
setError(errorLabel);
|
|
519
571
|
} finally {
|
|
@@ -550,8 +602,24 @@ const OtpFormBlock = ({
|
|
|
550
602
|
// never sent. (Same shape `submitCode` reads.)
|
|
551
603
|
const detail = signInErrorDetail(response);
|
|
552
604
|
|
|
553
|
-
|
|
554
|
-
|
|
605
|
+
const resendReading = readAuthError(detail, {
|
|
606
|
+
captcha: captchaLabel
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
if (response?.error && !resendReading.hasOtpChallenge) {
|
|
610
|
+
// The rate limit commerce actually reported, rather than "could not
|
|
611
|
+
// send the code" over and over while the shopper retries into a
|
|
612
|
+
// lockout. Field-level detail is folded in: there is no field on
|
|
613
|
+
// screen to hang it under at this point in the journey.
|
|
614
|
+
setError(
|
|
615
|
+
[
|
|
616
|
+
resendReading.formMessage,
|
|
617
|
+
...Object.values(resendReading.fieldMessages)
|
|
618
|
+
]
|
|
619
|
+
.filter(Boolean)
|
|
620
|
+
.join(' ') || sendErrorLabel
|
|
621
|
+
);
|
|
622
|
+
|
|
555
623
|
return;
|
|
556
624
|
}
|
|
557
625
|
|
|
@@ -672,6 +740,17 @@ const OtpFormBlock = ({
|
|
|
672
740
|
</div>
|
|
673
741
|
) : null;
|
|
674
742
|
|
|
743
|
+
/**
|
|
744
|
+
* What the composition's Form State containers read. A verification ends in
|
|
745
|
+
* a sign-in, so there is no success SCREEN — what can be scoped is the
|
|
746
|
+
* request running and the refusal. See `auth/form-state-context.tsx`.
|
|
747
|
+
*/
|
|
748
|
+
const formStateValue: AuthFormStateValue = {
|
|
749
|
+
screen: 'form',
|
|
750
|
+
busy: isBusy,
|
|
751
|
+
hasError: Boolean(error)
|
|
752
|
+
};
|
|
753
|
+
|
|
675
754
|
/**
|
|
676
755
|
* Which children this phase draws.
|
|
677
756
|
*
|
|
@@ -682,6 +761,19 @@ const OtpFormBlock = ({
|
|
|
682
761
|
* flow applies to its steps.
|
|
683
762
|
*/
|
|
684
763
|
const showsInPhase = (childBlock: BlockRendererProps['block']): boolean => {
|
|
764
|
+
// A state container answers to what the form is DOING rather than to which
|
|
765
|
+
// half it is on — a "sending the code…" panel or a designed refusal box —
|
|
766
|
+
// so it is filtered by state, not by phase.
|
|
767
|
+
if (childBlock.type === 'form-state') {
|
|
768
|
+
return showsInFormState({
|
|
769
|
+
block: childBlock,
|
|
770
|
+
state: formStateValue,
|
|
771
|
+
isDesigner,
|
|
772
|
+
selectedBlockId,
|
|
773
|
+
currentBreakpoint
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
685
777
|
if (childBlock.type !== 'otp-phase') return true;
|
|
686
778
|
|
|
687
779
|
if (
|
|
@@ -773,6 +865,7 @@ const OtpFormBlock = ({
|
|
|
773
865
|
// message is what a Form Error block draws wherever the design put it.
|
|
774
866
|
<OtpFormProvider value={api}>
|
|
775
867
|
<AuthFormErrorProvider value={error}>
|
|
868
|
+
<AuthFormStateProvider value={formStateValue}>
|
|
776
869
|
<AuthFieldErrorsProvider value={fieldErrors}>
|
|
777
870
|
<form
|
|
778
871
|
id={formHtmlId || undefined}
|
|
@@ -802,6 +895,7 @@ const OtpFormBlock = ({
|
|
|
802
895
|
{!errorIsAuthored && errorNode}
|
|
803
896
|
</form>
|
|
804
897
|
</AuthFieldErrorsProvider>
|
|
898
|
+
</AuthFormStateProvider>
|
|
805
899
|
</AuthFormErrorProvider>
|
|
806
900
|
</OtpFormProvider>
|
|
807
901
|
);
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useUpdateProfileMutation
|
|
8
8
|
} from '@akinon/next/data/client/account';
|
|
9
9
|
import { getResponsiveValue, partAttrs } from '../utils';
|
|
10
|
+
import { useHostModalCloser } from '../overlays/modal-host-context';
|
|
10
11
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
11
12
|
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
12
13
|
|
|
@@ -63,6 +64,17 @@ const ProfileFormBlock = ({
|
|
|
63
64
|
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
64
65
|
);
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Whether saving closes the modal this form is inside. Off by default: the
|
|
69
|
+
* "Saved" confirmation is the only feedback the shopper gets.
|
|
70
|
+
*/
|
|
71
|
+
const closeHostModalOnSuccess = readToggle(
|
|
72
|
+
block.properties?.closeHostModalOnSuccess,
|
|
73
|
+
currentBreakpoint,
|
|
74
|
+
false
|
|
75
|
+
);
|
|
76
|
+
const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
|
|
77
|
+
|
|
66
78
|
const submitLabel = text('submitLabel', 'Save changes');
|
|
67
79
|
const submitAlign = text('submitAlign', 'stretch');
|
|
68
80
|
const firstNameLabel = text('firstNameLabel', 'First name');
|
|
@@ -165,6 +177,7 @@ const ProfileFormBlock = ({
|
|
|
165
177
|
}).unwrap();
|
|
166
178
|
|
|
167
179
|
setStatus('saved');
|
|
180
|
+
closeHostModal();
|
|
168
181
|
} catch {
|
|
169
182
|
setStatus('error');
|
|
170
183
|
}
|