@akinon/pz-theme 2.0.95-beta.1 → 2.0.95-rc.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +33 -15
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-commerce-events.test.tsx +1 -5
  4. package/src/__tests__/auth-form-error-block.test.tsx +0 -2
  5. package/src/__tests__/auth-form-validation.test.tsx +0 -2
  6. package/src/__tests__/auth-slot-coverage.test.ts +1 -9
  7. package/src/__tests__/auth-step-title.test.tsx +0 -2
  8. package/src/__tests__/detached-submit-state.test.tsx +2 -80
  9. package/src/__tests__/forgot-password-composed.test.tsx +0 -2
  10. package/src/__tests__/otp-form-composed.test.tsx +0 -2
  11. package/src/__tests__/password-reset-step.test.tsx +0 -2
  12. package/src/__tests__/register-form-composed.test.tsx +4 -253
  13. package/src/__tests__/registry-parity.test.ts +0 -12
  14. package/src/auth/auth-field-errors.tsx +0 -31
  15. package/src/blocks/account-button-block.tsx +0 -25
  16. package/src/blocks/add-to-cart-block.tsx +0 -15
  17. package/src/blocks/auth-field-block.tsx +3 -13
  18. package/src/blocks/auth-flow-block.tsx +3 -45
  19. package/src/blocks/auth-form-block.tsx +54 -337
  20. package/src/blocks/block-renderer-registry.tsx +0 -8
  21. package/src/blocks/button-block.tsx +13 -43
  22. package/src/blocks/change-password-form-block.tsx +0 -15
  23. package/src/blocks/forgot-password-block.tsx +9 -132
  24. package/src/blocks/newsletter-form-block.tsx +0 -16
  25. package/src/blocks/otp-form-block.tsx +67 -161
  26. package/src/blocks/profile-form-block.tsx +0 -13
  27. package/src/blocks/register-form-block.tsx +110 -362
  28. package/src/chrome/__tests__/theme-config-source.test.ts +1 -2
  29. package/src/chrome/placeholder-slugs.ts +0 -3
  30. package/src/chrome/route-slug.ts +0 -11
  31. package/src/index.ts +0 -10
  32. package/src/overlays/__tests__/modal-registry.test.ts +1 -5
  33. package/src/overlays/modal-chrome-css.ts +1 -8
  34. package/src/overlays/modal-preview-host.tsx +2 -7
  35. package/src/overlays/modal-registry.ts +1 -38
  36. package/src/overlays/overlay-surface.tsx +0 -23
  37. package/src/overlays/overlay-tokens.ts +0 -12
  38. package/src/overlays/theme-modal-mount.tsx +6 -85
  39. package/src/utils/visibility-rules.ts +0 -114
  40. package/src/__tests__/auth-captcha.test.tsx +0 -261
  41. package/src/__tests__/auth-destination.test.tsx +0 -348
  42. package/src/__tests__/auth-error-reading.test.ts +0 -232
  43. package/src/__tests__/auth-flow-scope.test.tsx +0 -162
  44. package/src/__tests__/auth-form-success-block.test.tsx +0 -429
  45. package/src/__tests__/block-action-audience.test.ts +0 -154
  46. package/src/__tests__/form-state-composition.test.tsx +0 -484
  47. package/src/__tests__/modal-login-close.test.tsx +0 -366
  48. package/src/__tests__/reset-password-composed.test.tsx +0 -385
  49. package/src/auth/auth-captcha.tsx +0 -125
  50. package/src/auth/auth-destination.ts +0 -168
  51. package/src/auth/form-state-context.tsx +0 -170
  52. package/src/auth/form-success-context.tsx +0 -56
  53. package/src/auth/read-auth-error.ts +0 -332
  54. package/src/blocks/auth-success-block.tsx +0 -85
  55. package/src/blocks/captcha-block.tsx +0 -61
  56. package/src/blocks/form-state-block.tsx +0 -84
  57. package/src/blocks/reset-password-block.tsx +0 -717
  58. package/src/overlays/__tests__/modal-loading.test.tsx +0 -235
  59. package/src/overlays/modal-host-context.tsx +0 -66
  60. package/src/overlays/overlay-loading.tsx +0 -144
package/CHANGELOG.md CHANGED
@@ -1,20 +1,38 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.95-beta.1
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.95-beta.1
9
- - @akinon/theme-contract@2.0.95-beta.1
10
-
11
- ## 2.0.95-beta.0
12
-
13
- ### Patch Changes
14
-
15
- - Updated dependencies [cbbbfd757]
16
- - @akinon/next@2.0.95-beta.0
17
- - @akinon/theme-contract@2.0.95-beta.0
3
+ ## 2.0.95-rc.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [86187b1c]
8
+ - Updated dependencies [0cf9ea239]
9
+ - Updated dependencies [324f97d55]
10
+ - Updated dependencies [51ea06888]
11
+ - Updated dependencies
12
+ - Updated dependencies [2fea41430]
13
+ - Updated dependencies [b55acb768]
14
+ - Updated dependencies [760258c1c]
15
+ - Updated dependencies [143be2b9d]
16
+ - Updated dependencies [7889b08fe]
17
+ - Updated dependencies [9f8cd3bc5]
18
+ - Updated dependencies [bfafa3f49]
19
+ - Updated dependencies [57d7eb305]
20
+ - Updated dependencies [d99a6a7d5]
21
+ - Updated dependencies [9db81a714]
22
+ - Updated dependencies [591e345e1]
23
+ - Updated dependencies [4de5303c5]
24
+ - Updated dependencies [95b139dc1]
25
+ - Updated dependencies [1d00f2d06]
26
+ - Updated dependencies [4ac7b2a1e]
27
+ - Updated dependencies [e9598c71d]
28
+ - Updated dependencies [4998a9631]
29
+ - Updated dependencies [804d2bd6c]
30
+ - Updated dependencies [3909d3224]
31
+ - Updated dependencies [6a3d8a631]
32
+ - Updated dependencies [e18836b20]
33
+ - Updated dependencies [b1111d7b]
34
+ - @akinon/next@2.0.95-rc.0
35
+ - @akinon/theme-contract@2.0.95-rc.0
18
36
 
19
37
  ## 2.0.94
20
38
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.95-beta.1",
4
+ "version": "2.0.95-rc.0",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.95-beta.1",
11
+ "@akinon/next": "2.0.95-rc.0",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.95-beta.1",
16
+ "@akinon/theme-contract": "2.0.95-rc.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.95-beta.1",
24
+ "@akinon/next": "2.0.95-rc.0",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -32,11 +32,7 @@ jest.mock('next/navigation', () => ({
32
32
  refresh: jest.fn(),
33
33
  replace: jest.fn()
34
34
  }),
35
- usePathname: () => '/account/orders',
36
- // The login form reads the query too, since where a sign-in leaves the
37
- // shopper can come from `?callbackUrl`/`?next` — a double without this
38
- // throws before a single event can be measured.
39
- useSearchParams: () => new URLSearchParams()
35
+ usePathname: () => '/account/orders'
40
36
  }));
41
37
 
42
38
  jest.mock('@akinon/next/data/client/user', () => ({
@@ -34,8 +34,6 @@ jest.mock('next-auth/react', () => ({
34
34
  }));
35
35
 
36
36
  jest.mock('next/navigation', () => ({
37
- usePathname: () => '/',
38
- useSearchParams: () => new URLSearchParams(),
39
37
  useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
40
38
  }));
41
39
 
@@ -22,8 +22,6 @@ jest.mock('next-auth/react', () => ({
22
22
  }));
23
23
 
24
24
  jest.mock('next/navigation', () => ({
25
- usePathname: () => '/',
26
- useSearchParams: () => new URLSearchParams(),
27
25
  useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
28
26
  }));
29
27
 
@@ -23,12 +23,7 @@ const APP_DIR = path.resolve(
23
23
  /** Placeholder key -> the file (relative to `[pz]`) mounting it. */
24
24
  const EXPECTED_MOUNTS: Record<string, string> = {
25
25
  auth: 'auth/(login)/layout.tsx',
26
- forgotPassword: 'users/password/reset/layout.tsx',
27
- // The screen the reset MAIL lands on, which is a different page from the one
28
- // that asks for the mail. It was the last auth slug the editor offered and
29
- // nothing mounted: a designer could compose the screen, publish it, and the
30
- // shopper coming out of their inbox still met the hand-written page.
31
- resetPassword: 'users/reset/[[...id]]/layout.tsx'
26
+ forgotPassword: 'users/password/reset/layout.tsx'
32
27
  };
33
28
 
34
29
  /**
@@ -57,9 +52,6 @@ const AUTH_PICKER_PATHS = [
57
52
  '/register',
58
53
  '/auth/forgot-password',
59
54
  '/users/password/reset',
60
- // The mail's own link, token and all — a catch-all route, so the path has
61
- // segments the rule has to see past.
62
- '/users/reset/tok/abc123',
63
55
  '/auth/guest',
64
56
  '/users/auth/guest'
65
57
  ];
@@ -18,8 +18,6 @@ import { Block } from '../theme-block';
18
18
  */
19
19
 
20
20
  jest.mock('next/navigation', () => ({
21
- usePathname: () => '/',
22
- useSearchParams: () => new URLSearchParams(),
23
21
  useRouter: () => ({ push: jest.fn(), refresh: jest.fn() })
24
22
  }));
25
23
 
@@ -59,8 +59,6 @@ jest.mock('@akinon/next/data/client/user', () => {
59
59
  });
60
60
 
61
61
  jest.mock('next/navigation', () => ({
62
- usePathname: () => '/',
63
- useSearchParams: () => new URLSearchParams(),
64
62
  useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
65
63
  }));
66
64
 
@@ -74,15 +72,10 @@ const blockOf = (type: string, properties: Record<string, unknown>): Block => ({
74
72
  hidden: false
75
73
  });
76
74
 
77
- const renderCta = (properties: string | Record<string, unknown>) =>
75
+ const renderCta = (submitFormId: string) =>
78
76
  render(
79
77
  <ButtonBlock
80
- block={blockOf(
81
- 'button',
82
- typeof properties === 'string'
83
- ? { text: 'Continue', submitFormId: properties }
84
- : { text: 'Continue', ...properties }
85
- )}
78
+ block={blockOf('button', { text: 'Continue', submitFormId })}
86
79
  placeholderId="placeholder-1"
87
80
  sectionId="section-1"
88
81
  currentBreakpoint="desktop"
@@ -221,74 +214,3 @@ describe('a detached submit CTA', () => {
221
214
  expect(forgotPassword).not.toHaveBeenCalled();
222
215
  });
223
216
  });
224
-
225
- /**
226
- * What the shopper SEES while the request is in flight.
227
- *
228
- * `disabled` alone changed nothing visible: a Button carries an authored
229
- * background and colour, and those override what a browser paints on a
230
- * disabled button — so a drawer's bottom-bar CTA stayed bright and
231
- * clickable-looking for the whole POST, which is what makes someone press it
232
- * twice. The state now travels as part state (so a designer can dress it) and
233
- * carries a default dimming (so it reads as busy with nothing authored).
234
- */
235
- describe('a detached CTA while its form is working', () => {
236
- it('says so in its part state and dims itself', () => {
237
- const { container } = renderCta({ submitFormId: 'login-form' });
238
-
239
- const button = container.querySelector('button') as HTMLButtonElement;
240
-
241
- expect(button.getAttribute('data-state')).toBeNull();
242
-
243
- act(() => {
244
- publishFormSubmitState('login-form', true);
245
- });
246
-
247
- expect(button.getAttribute('data-state')).toContain('loading');
248
- expect(button.getAttribute('data-state')).toContain('disabled');
249
- expect(button.style.opacity).toBe('0.7');
250
- expect(button.style.cursor).toBe('not-allowed');
251
- expect(button).toBeDisabled();
252
-
253
- act(() => {
254
- publishFormSubmitState('login-form', false);
255
- });
256
-
257
- expect(button.getAttribute('data-state')).toBeNull();
258
- expect(button.style.opacity).not.toBe('0.7');
259
- });
260
-
261
- it('swaps its caption only when the designer authored one', () => {
262
- const { container } = renderCta({
263
- submitFormId: 'login-form',
264
- text: 'Devam Et',
265
- busyText: 'Gönderiliyor…'
266
- });
267
-
268
- act(() => {
269
- publishFormSubmitState('login-form', true);
270
- });
271
-
272
- expect(container.textContent).toContain('Gönderiliyor…');
273
-
274
- act(() => {
275
- publishFormSubmitState('login-form', false);
276
- });
277
-
278
- expect(container.textContent).toContain('Devam Et');
279
- });
280
-
281
- it('keeps the caption put when no busy text was authored', () => {
282
- const { container } = renderCta({
283
- submitFormId: 'login-form',
284
- text: 'Devam Et'
285
- });
286
-
287
- act(() => {
288
- publishFormSubmitState('login-form', true);
289
- });
290
-
291
- // Absence-preserving, exactly like the forms' own `busyLabel`.
292
- expect(container.textContent).toContain('Devam Et');
293
- });
294
- });
@@ -67,8 +67,6 @@ jest.mock('@akinon/next/data/client/user', () => {
67
67
  });
68
68
 
69
69
  jest.mock('next/navigation', () => ({
70
- usePathname: () => '/',
71
- useSearchParams: () => new URLSearchParams(),
72
70
  useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
73
71
  }));
74
72
 
@@ -33,8 +33,6 @@ jest.mock('next-auth/react', () => ({
33
33
  }));
34
34
 
35
35
  jest.mock('next/navigation', () => ({
36
- usePathname: () => '/',
37
- useSearchParams: () => new URLSearchParams(),
38
36
  useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
39
37
  }));
40
38
 
@@ -34,8 +34,6 @@ jest.mock('next-auth/react', () => ({
34
34
  }));
35
35
 
36
36
  jest.mock('next/navigation', () => ({
37
- usePathname: () => '/',
38
- useSearchParams: () => new URLSearchParams(),
39
37
  useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
40
38
  }));
41
39
 
@@ -1,16 +1,7 @@
1
- import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
2
  import React from 'react';
3
3
 
4
4
  import RegisterFormBlock from '../blocks/register-form-block';
5
- import ThemeModalMount from '../overlays/theme-modal-mount';
6
- import { readThemeModals } from '../overlays/modal-registry';
7
- import {
8
- __resetOverlayBusForTests,
9
- isModalOpen,
10
- openModal,
11
- registerModalHost
12
- } from '../overlays/overlay-bus';
13
- import { __resetModalPreviewForTests } from '../overlays/use-modal-preview';
14
5
  import { AUTH_CONSENT_NAMES } from '../blocks/auth-consent-block';
15
6
  import {
16
7
  __resetAuthFlowBusForTests,
@@ -45,8 +36,6 @@ const push = jest.fn();
45
36
  const refresh = jest.fn();
46
37
 
47
38
  jest.mock('next/navigation', () => ({
48
- usePathname: () => '/',
49
- useSearchParams: () => new URLSearchParams(),
50
39
  useRouter: () => ({ push, refresh, replace: jest.fn() })
51
40
  }));
52
41
 
@@ -132,8 +121,6 @@ beforeEach(() => {
132
121
  push.mockClear();
133
122
  refresh.mockClear();
134
123
  __resetAuthFlowBusForTests();
135
- __resetOverlayBusForTests();
136
- __resetModalPreviewForTests();
137
124
  });
138
125
 
139
126
  describe('a composed register form', () => {
@@ -171,10 +158,7 @@ describe('a composed register form', () => {
171
158
  await waitFor(() => expect(signIn).toHaveBeenCalled());
172
159
 
173
160
  expect(submitted()).toMatchObject({
174
- // `register`, not `registration`: the credentials provider resolves the
175
- // endpoint as `user[formType]` and that map has only `register`, so the
176
- // old value POSTed to `${commerceUrl}undefined`.
177
- formType: 'register',
161
+ formType: 'registration',
178
162
  first_name: 'Ada',
179
163
  last_name: 'Lovelace',
180
164
  email: 'ada@example.com',
@@ -353,7 +337,7 @@ describe('the SMS handoff from a composed registration', () => {
353
337
  // the sign-up used — password included, which is why it never outlives the
354
338
  // journey (see `registerAuthFlowMount`).
355
339
  expect(pending.payload).toMatchObject({
356
- formType: 'register',
340
+ formType: 'registration',
357
341
  email: 'ada@example.com',
358
342
  is_allowed: true
359
343
  });
@@ -637,7 +621,7 @@ describe('the body a composition posts', () => {
637
621
  fireEvent.submit(container.querySelector('form')!);
638
622
 
639
623
  await waitFor(() => expect(signIn).toHaveBeenCalled());
640
- expect(submitted()).toMatchObject({ formType: 'register' });
624
+ expect(submitted()).toMatchObject({ formType: 'registration' });
641
625
  // False, not the string: a truthy `redirect` navigates away instead of
642
626
  // answering, and the form would never see its own errors again.
643
627
  expect(submitted().redirect).toBe(false);
@@ -753,236 +737,3 @@ describe('a field the design has taken off this breakpoint', () => {
753
737
  expect(submitted()).not.toHaveProperty('phone');
754
738
  });
755
739
  });
756
-
757
- /**
758
- * The four marketing channels commerce records separately.
759
- *
760
- * They used to be fanned out from the single marketing tick, which turned a
761
- * composition that asks per channel into a lie: a shopper who ticked e-mail and
762
- * left SMS alone was recorded as refusing all four. A consent record is a legal
763
- * record, so what travels is what the shopper actually answered.
764
- */
765
- describe('per-channel marketing permissions', () => {
766
- const channel = (name: string, order: number): Block =>
767
- block({
768
- id: `consent-${name}`,
769
- type: 'auth-consent',
770
- label: name,
771
- properties: { consentType: 'custom', name, tickedValue: 'true' },
772
- order
773
- });
774
-
775
- it('sends each channel its own answer when the composition asks per channel', async () => {
776
- const { container } = renderComposed([
777
- field('email', 0),
778
- field('password', 1),
779
- consent('legal', 2),
780
- channel('email_allowed', 3),
781
- channel('sms_allowed', 4)
782
- ]);
783
-
784
- fill(container, { email: 'ada@example.com', password: 'hunter2' });
785
- tick(container, AUTH_CONSENT_NAMES.legal);
786
- tick(container, 'email_allowed');
787
-
788
- fireEvent.submit(container.querySelector('form')!);
789
-
790
- await waitFor(() => expect(signIn).toHaveBeenCalled());
791
-
792
- const body = submitted();
793
-
794
- expect(body.email_allowed).toBe(true);
795
- // The refusal the shopper actually gave, not the one the blanket tick
796
- // would have written over it.
797
- expect(body.sms_allowed).toBe(false);
798
- // Channels nobody asked about keep falling back to the blanket answer,
799
- // which is what a fixed form and every published composition send.
800
- expect(body.call_allowed).toBe(false);
801
- expect(body.whatsapp_allowed).toBe(false);
802
- // A flag re-expressed as a boolean must not also travel as `'true'`.
803
- expect(body.email_allowed).not.toBe('true');
804
- });
805
-
806
- it('keeps the blanket tick for a composition that asks once', async () => {
807
- const { container } = renderComposed(COMPOSED);
808
-
809
- fill(container, {
810
- first_name: 'Ada',
811
- last_name: 'Lovelace',
812
- email: 'ada@example.com',
813
- password: 'hunter2',
814
- phone: '5551234567'
815
- });
816
- tick(container, AUTH_CONSENT_NAMES.legal);
817
- tick(container, AUTH_CONSENT_NAMES.marketing);
818
-
819
- fireEvent.submit(container.querySelector('form')!);
820
-
821
- await waitFor(() => expect(signIn).toHaveBeenCalled());
822
-
823
- expect(submitted()).toMatchObject({
824
- email_allowed: true,
825
- sms_allowed: true,
826
- call_allowed: true,
827
- whatsapp_allowed: true
828
- });
829
- });
830
-
831
- it('lets a composition answer `confirm` on its own', async () => {
832
- const { container } = renderComposed([
833
- field('email', 0),
834
- field('password', 1),
835
- consent('legal', 2),
836
- channel('confirm', 3)
837
- ]);
838
-
839
- fill(container, { email: 'ada@example.com', password: 'hunter2' });
840
- tick(container, AUTH_CONSENT_NAMES.legal);
841
-
842
- fireEvent.submit(container.querySelector('form')!);
843
-
844
- await waitFor(() => expect(signIn).toHaveBeenCalled());
845
-
846
- // The agreement was accepted; the separate confirmation was not, and that
847
- // is the answer commerce is told — rather than a copy of `is_allowed`.
848
- expect(submitted()).toMatchObject({ is_allowed: true, confirm: false });
849
- });
850
- });
851
-
852
- /**
853
- * A refusal that names a field belongs under that field.
854
- *
855
- * "This e-mail is already registered" used to reach the shopper as the raw
856
- * `[{"type":"field_errors",…}]` above the form, because the reader kept a
857
- * message only when it was a string and commerce sends `string[]`.
858
- */
859
- describe('a refusal commerce hangs on a field', () => {
860
- const refuse = (entries: { name: string; value: unknown }[]) =>
861
- signIn.mockImplementationOnce(() =>
862
- Promise.resolve({
863
- error: 'CredentialsSignin',
864
- code: JSON.stringify([{ type: 'field_errors', data: entries }])
865
- })
866
- );
867
-
868
- const fillValid = (container: HTMLElement) => {
869
- fill(container, { email: 'ada@example.com', password: 'hunter2' });
870
- tick(container, AUTH_CONSENT_NAMES.legal);
871
- fireEvent.submit(container.querySelector('form')!);
872
- };
873
-
874
- it('reads under the field, and leaves the form-level slot empty', async () => {
875
- refuse([{ name: 'email', value: ['A user with this e-mail exists.'] }]);
876
-
877
- const { container } = renderComposed([
878
- field('email', 0),
879
- field('password', 1),
880
- consent('legal', 2)
881
- ]);
882
-
883
- fillValid(container);
884
-
885
- await waitFor(() =>
886
- expect(
887
- screen.getByText('A user with this e-mail exists.')
888
- ).toBeInTheDocument()
889
- );
890
-
891
- const emailInput = container.querySelector(
892
- 'input[name="email"]'
893
- ) as HTMLInputElement;
894
-
895
- expect(emailInput.getAttribute('aria-invalid')).toBe('true');
896
- // The authored fallback is for a refusal nobody could read, not for one
897
- // whose every sentence already found a field.
898
- expect(screen.queryByText('Registration failed.')).toBeNull();
899
- // And the caret goes where the work is, as it does after client validation.
900
- expect(document.activeElement).toBe(emailInput);
901
- });
902
-
903
- it('lifts a message for a field this composition never authored', async () => {
904
- refuse([{ name: 'tckn', value: ['This field is required.'] }]);
905
-
906
- const { container } = renderComposed([
907
- field('email', 0),
908
- field('password', 1),
909
- consent('legal', 2)
910
- ]);
911
-
912
- fillValid(container);
913
-
914
- // Not silently dropped into a provider nothing reads: the shopper is told,
915
- // in the one slot that always exists.
916
- await waitFor(() =>
917
- expect(
918
- screen.getByText('This field is required.')
919
- ).toBeInTheDocument()
920
- );
921
- });
922
- });
923
-
924
- /**
925
- * A sign-up that commerce escalates to SMS must NOT close the drawer.
926
- *
927
- * The parked payload is what an `otp-form` finishes the registration with, so
928
- * closing the surface would strand it: the shopper never reaches the code
929
- * screen and the account is never created. Safe today by one `return` before
930
- * the close, which is exactly the kind of ordering that a later edit breaks
931
- * silently — hence this case.
932
- */
933
- describe('an SMS escalation inside a drawer', () => {
934
- it('moves to the verification step and leaves the drawer open', async () => {
935
- signIn.mockImplementationOnce(() =>
936
- Promise.resolve({
937
- error: 'CredentialsSignin',
938
- code: JSON.stringify([{ type: 'otp' }])
939
- })
940
- );
941
-
942
- const modal = readThemeModals([
943
- { id: 'auth-drawer', name: 'Auth', presentation: 'drawer' }
944
- ])[0];
945
-
946
- registerModalHost();
947
-
948
- const { container } = render(
949
- <ThemeModalMount modal={modal}>
950
- <RegisterFormBlock
951
- block={
952
- block({
953
- id: 'register-1',
954
- type: 'register-form',
955
- label: 'Register Form',
956
- properties: { otpStepTarget: 'auth:verify' },
957
- blocks: COMPOSED
958
- }) as Block
959
- }
960
- placeholderId="placeholder-1"
961
- sectionId="section-1"
962
- currentBreakpoint="desktop"
963
- />
964
- </ThemeModalMount>
965
- );
966
-
967
- act(() => {
968
- openModal('auth-drawer');
969
- });
970
-
971
- fill(container, {
972
- first_name: 'Ada',
973
- last_name: 'Lovelace',
974
- email: 'ada@example.com',
975
- password: 'hunter2',
976
- phone: '5551234567'
977
- });
978
- tick(container, AUTH_CONSENT_NAMES.legal);
979
- fireEvent.submit(container.querySelector('form')!);
980
-
981
- await waitFor(() =>
982
- expect(getAuthFlowState('auth')?.active).toBe('verify')
983
- );
984
-
985
- expect(isModalOpen('auth-drawer')).toBe(true);
986
- expect(getPendingOtp()?.kind).toBe('register');
987
- });
988
- });
@@ -39,17 +39,9 @@ const EXPECTED_BLOCK_TYPES = [
39
39
  'auth-form',
40
40
  'auth-step',
41
41
  'auth-step-title',
42
- // The confirmation as a placeable block, the success twin of 'auth-error':
43
- // the two forms that have a success state publish their message and this
44
- // draws it where the design put it. See `auth/form-success-context.tsx`.
45
- 'auth-success',
46
42
  'basket-items',
47
43
  'basket-summary',
48
44
  'button',
49
- // The security check commerce escalates to, placed inside an auth form.
50
- 'captcha',
51
- // The screen the password-reset mail lands on.
52
- 'reset-password',
53
45
  'change-password-form',
54
46
  'checkout-flow-outlet',
55
47
  'checkout-steps',
@@ -68,10 +60,6 @@ const EXPECTED_BLOCK_TYPES = [
68
60
  'favorite-button',
69
61
  'favourites-grid',
70
62
  'forgot-password',
71
- // One screen of a composed form — the confirmation, the refusal, the busy
72
- // moment — as a container the form draws when it is on that screen. See
73
- // `auth/form-state-context.tsx`.
74
- 'form-state',
75
63
  'group',
76
64
  'hamburger-menu',
77
65
  'hotspot',
@@ -132,37 +132,6 @@ export const collectAuthFieldErrors = (
132
132
  return { errors, firstInvalid };
133
133
  };
134
134
 
135
- /**
136
- * Put the caret where the work is after a SERVER refusal.
137
- *
138
- * The client-side collector already does this for what it finds itself
139
- * (`firstInvalid`), and a refusal that arrives from commerce deserves the same
140
- * treatment: a duplicate e-mail three fields up is a message the shopper has
141
- * to scroll for otherwise. Document order rather than the order commerce
142
- * listed its errors in, because the shopper reads the form, not the response.
143
- *
144
- * A control the design took off this breakpoint is skipped for the same reason
145
- * the collector skips it: focus cannot land on something nobody can see.
146
- */
147
- export const focusFirstAuthFieldError = (
148
- form: HTMLFormElement | null | undefined,
149
- errors: AuthFieldErrors
150
- ): void => {
151
- if (!form || Object.keys(errors).length === 0) return;
152
-
153
- const controls = Array.from(
154
- form.querySelectorAll<FormControl>(
155
- 'input[name], select[name], textarea[name]'
156
- )
157
- );
158
-
159
- const first = controls.find(
160
- (control) => Boolean(errors[control.name]) && isVisibleControl(control)
161
- );
162
-
163
- first?.focus();
164
- };
165
-
166
135
  /**
167
136
  * The same map with one field's message dropped — what a form does the moment
168
137
  * that field is edited, so a message never outlives the mistake it describes.