@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +34 -0
  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/account-button-block.tsx +25 -0
  30. package/src/blocks/add-to-cart-block.tsx +15 -0
  31. package/src/blocks/auth-field-block.tsx +13 -3
  32. package/src/blocks/auth-flow-block.tsx +45 -3
  33. package/src/blocks/auth-form-block.tsx +337 -54
  34. package/src/blocks/auth-success-block.tsx +85 -0
  35. package/src/blocks/block-renderer-registry.tsx +8 -0
  36. package/src/blocks/button-block.tsx +43 -13
  37. package/src/blocks/captcha-block.tsx +61 -0
  38. package/src/blocks/change-password-form-block.tsx +15 -0
  39. package/src/blocks/forgot-password-block.tsx +132 -9
  40. package/src/blocks/form-state-block.tsx +84 -0
  41. package/src/blocks/newsletter-form-block.tsx +16 -0
  42. package/src/blocks/otp-form-block.tsx +161 -67
  43. package/src/blocks/profile-form-block.tsx +13 -0
  44. package/src/blocks/register-form-block.tsx +362 -110
  45. package/src/blocks/reset-password-block.tsx +717 -0
  46. package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
  47. package/src/chrome/placeholder-slugs.ts +3 -0
  48. package/src/chrome/route-slug.ts +11 -0
  49. package/src/index.ts +10 -0
  50. package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
  51. package/src/overlays/__tests__/modal-registry.test.ts +5 -1
  52. package/src/overlays/modal-chrome-css.ts +8 -1
  53. package/src/overlays/modal-host-context.tsx +66 -0
  54. package/src/overlays/modal-preview-host.tsx +7 -2
  55. package/src/overlays/modal-registry.ts +38 -1
  56. package/src/overlays/overlay-loading.tsx +144 -0
  57. package/src/overlays/overlay-surface.tsx +23 -0
  58. package/src/overlays/overlay-tokens.ts +12 -0
  59. package/src/overlays/theme-modal-mount.tsx +85 -6
  60. 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 { BlockRendererProps } from './block-renderer-registry';
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
- * pz brands serialise an AuthError[] into next-auth's `error` string, but a
38
- * plain string is also possible, so both shapes are handled. Kept local rather
39
- * than imported from auth-form-block, for the same reason register-form keeps
40
- * its own copy: exporting it would change a shipped module's public surface.
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 readAuthError = (error: string | null | undefined): string => {
43
- if (!error) return '';
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
- // Defaults on: the commonest home for this block is a drawer, and a drawer
252
- // still covering the page the shopper just signed into reads as a hang.
253
- // closeAllModals is a no-op when nothing is open, so pages pay nothing.
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
- true
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
- setError(readAuthError(signInErrorDetail(response)) || errorLabel);
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
- router.push(response.url || callbackUrl);
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
- if (response?.error && !hasOtpChallenge(detail)) {
554
- setError(readAuthError(detail) || sendErrorLabel);
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
  }