@akinon/pz-theme 2.0.94 → 2.0.95-beta.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 +16 -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
@@ -0,0 +1,366 @@
1
+ import { act, fireEvent, render, waitFor } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import AuthFormBlock from '../blocks/auth-form-block';
5
+ import NewsletterFormBlock from '../blocks/newsletter-form-block';
6
+ import ThemeModalMount from '../overlays/theme-modal-mount';
7
+ import { readThemeModals, ThemeModal } from '../overlays/modal-registry';
8
+ import {
9
+ __resetOverlayBusForTests,
10
+ getOpenModalIds,
11
+ isModalOpen,
12
+ openModal,
13
+ registerModalHost
14
+ } from '../overlays/overlay-bus';
15
+ import { clearAutoOpenRecord } from '../overlays/modal-frequency';
16
+ import { __resetModalPreviewForTests } from '../overlays/use-modal-preview';
17
+ import { Block } from '../theme-block';
18
+
19
+ /**
20
+ * A shopper who signs in inside the drawer is done with the drawer.
21
+ *
22
+ * It used to stay open over the page, and the reason was a default in the
23
+ * wrong place: the only close a form could perform was `closeAllModals()`
24
+ * behind an authored toggle, and that toggle is off by default — correctly,
25
+ * since "close every modal on the page" is not something a form on a plain
26
+ * page should do unasked. The live GANT drawer shows how that ends: its OTP
27
+ * form has the toggle on and its login and register forms do not, so signing
28
+ * in with an e-mail left the drawer covering the homepage.
29
+ *
30
+ * With the host's id in hand the two defaults stop fighting: a form closes THE
31
+ * MODAL IT IS IN, and a form that is not in one still closes nothing.
32
+ */
33
+
34
+ const signIn = jest.fn(() => Promise.resolve({ error: null, url: '/account' }));
35
+
36
+ jest.mock('next-auth/react', () => ({
37
+ signIn: (...args: unknown[]) => signIn(...args),
38
+ useSession: () => ({ data: null, status: 'unauthenticated' })
39
+ }));
40
+
41
+ const push = jest.fn();
42
+ const refresh = jest.fn();
43
+
44
+ jest.mock('next/navigation', () => ({
45
+ useRouter: () => ({ push, refresh, replace: jest.fn() }),
46
+ useSearchParams: () => new URLSearchParams(),
47
+ usePathname: () => '/'
48
+ }));
49
+
50
+ jest.mock('@akinon/next/hooks/use-localization', () => ({
51
+ useLocalization: () => ({ locale: 'en', currency: 'USD' })
52
+ }));
53
+
54
+ const subscribe = jest.fn(() => ({ unwrap: () => Promise.resolve({}) }));
55
+
56
+ jest.mock('@akinon/next/data/client/misc', () => ({
57
+ useEmailSubscriptionMutation: () => [subscribe, { isLoading: false }]
58
+ }));
59
+
60
+ const block = (overrides: Partial<Block>): Block =>
61
+ ({
62
+ id: 'blk',
63
+ type: 'text',
64
+ label: 'Block',
65
+ value: '',
66
+ order: 0,
67
+ hidden: false,
68
+ properties: {},
69
+ styles: {},
70
+ ...overrides
71
+ }) as Block;
72
+
73
+ const loginForm = (properties: Record<string, unknown> = {}) => (
74
+ <AuthFormBlock
75
+ block={
76
+ block({
77
+ id: 'login-1',
78
+ type: 'auth-form',
79
+ label: 'Auth Form',
80
+ properties
81
+ }) as Block
82
+ }
83
+ placeholderId="placeholder-1"
84
+ sectionId="section-1"
85
+ currentBreakpoint="desktop"
86
+ />
87
+ );
88
+
89
+ const mkModal = (id: string): ThemeModal =>
90
+ readThemeModals([{ id, name: 'Auth', presentation: 'drawer' }])[0];
91
+
92
+ /** The drawer, with the login form composed inside it — the GANT shape. */
93
+ const openDrawerWithLogin = (
94
+ properties: Record<string, unknown> = {},
95
+ modalId = 'auth-drawer'
96
+ ) => {
97
+ const view = render(
98
+ <ThemeModalMount modal={mkModal(modalId)}>
99
+ {loginForm(properties)}
100
+ </ThemeModalMount>
101
+ );
102
+
103
+ act(() => {
104
+ openModal(modalId);
105
+ });
106
+
107
+ return view;
108
+ };
109
+
110
+ const submit = (container: HTMLElement) => {
111
+ fireEvent.change(container.querySelector('input[name="email"]')!, {
112
+ target: { value: 'ada@example.com' }
113
+ });
114
+ fireEvent.change(container.querySelector('input[name="password"]')!, {
115
+ target: { value: 'hunter2' }
116
+ });
117
+ fireEvent.submit(container.querySelector('form')!);
118
+ };
119
+
120
+ beforeEach(() => {
121
+ signIn.mockClear();
122
+ push.mockClear();
123
+ refresh.mockClear();
124
+ __resetOverlayBusForTests();
125
+ __resetModalPreviewForTests();
126
+ registerModalHost();
127
+ clearAutoOpenRecord('auth-drawer');
128
+ clearAutoOpenRecord('other');
129
+ });
130
+
131
+ describe('a login inside a drawer', () => {
132
+ it('closes the drawer it was in, with nothing authored', async () => {
133
+ const { container } = openDrawerWithLogin();
134
+
135
+ expect(isModalOpen('auth-drawer')).toBe(true);
136
+
137
+ submit(container);
138
+
139
+ await waitFor(() => expect(isModalOpen('auth-drawer')).toBe(false));
140
+ // And the surface is gone with it, not merely marked closed.
141
+ expect(document.querySelector('[data-theme-modal]')).toBeNull();
142
+ });
143
+
144
+ it('stays on the page instead of pushing the shopper to the homepage', async () => {
145
+ const { container } = openDrawerWithLogin();
146
+
147
+ submit(container);
148
+
149
+ await waitFor(() => expect(refresh).toHaveBeenCalled());
150
+ // A sign-in inside a drawer is a detour from the page the shopper is on,
151
+ // so `stay` is the default THERE — the refresh is the whole navigation.
152
+ expect(push).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it('still obeys a composition that asked to be sent somewhere', async () => {
156
+ const { container } = openDrawerWithLogin({
157
+ afterSignIn: 'fixed',
158
+ callbackUrl: '/account'
159
+ });
160
+
161
+ submit(container);
162
+
163
+ await waitFor(() => expect(push).toHaveBeenCalledWith('/account'));
164
+ // Authored destination or not, the drawer it finished in still closes.
165
+ expect(isModalOpen('auth-drawer')).toBe(false);
166
+ });
167
+
168
+ it('leaves other modals alone unless asked to close everything', async () => {
169
+ const { container } = openDrawerWithLogin();
170
+
171
+ act(() => {
172
+ openModal('other');
173
+ });
174
+
175
+ submit(container);
176
+
177
+ await waitFor(() => expect(isModalOpen('auth-drawer')).toBe(false));
178
+ // Closing the surface a form finished in is never a surprise; closing an
179
+ // unrelated overlay would be.
180
+ expect(isModalOpen('other')).toBe(true);
181
+ });
182
+
183
+ it('closes every open modal when the composition authored that', async () => {
184
+ const { container } = openDrawerWithLogin({ closeModalsOnSuccess: true });
185
+
186
+ act(() => {
187
+ openModal('other');
188
+ });
189
+
190
+ submit(container);
191
+
192
+ // The wider meaning the toggle has always had, for a form nested two
193
+ // overlays deep.
194
+ await waitFor(() => expect(getOpenModalIds()).toHaveLength(0));
195
+ });
196
+ });
197
+
198
+ describe('a login on a plain page', () => {
199
+ it('closes nothing, and keeps its historical destination', async () => {
200
+ const { container } = render(loginForm());
201
+
202
+ act(() => {
203
+ openModal('other');
204
+ });
205
+
206
+ submit(container);
207
+
208
+ await waitFor(() => expect(push).toHaveBeenCalledWith('/'));
209
+ // No host to close, so a form on a page cannot reach an overlay it knows
210
+ // nothing about — which is why the toggle defaults to off.
211
+ expect(isModalOpen('other')).toBe(true);
212
+ });
213
+ });
214
+
215
+ /**
216
+ * The destination, decided on the VALUE rather than on a default.
217
+ *
218
+ * A host-aware DEFAULT would have been dead on arrival: the editor writes every
219
+ * select's default into the block it inserts, so `afterSignIn` arrives as
220
+ * `'fixed'` on every newly authored form and a host-aware default would only
221
+ * ever apply to compositions that predate the property. Reading the resolved
222
+ * PATH instead survives that, and it keeps an authored destination working.
223
+ */
224
+ describe('where a drawer sign-in leaves the shopper', () => {
225
+ it('goes nowhere when the destination names nowhere in particular', async () => {
226
+ // `/` is the shipped default that every inserted form carries.
227
+ const { container } = openDrawerWithLogin({ callbackUrl: '/' });
228
+
229
+ submit(container);
230
+
231
+ await waitFor(() => expect(refresh).toHaveBeenCalled());
232
+ expect(push).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it('still honours a destination the brand actually chose', async () => {
236
+ const { container } = openDrawerWithLogin({ callbackUrl: '/account' });
237
+
238
+ submit(container);
239
+
240
+ await waitFor(() => expect(push).toHaveBeenCalledWith('/account'));
241
+ });
242
+
243
+ it('keeps pushing on a plain page, where `/` is a real destination', async () => {
244
+ const { container } = render(loginForm({ callbackUrl: '/' }));
245
+
246
+ submit(container);
247
+
248
+ await waitFor(() => expect(push).toHaveBeenCalledWith('/'));
249
+ });
250
+ });
251
+
252
+ /**
253
+ * The same rule, for everything else that finishes inside a modal.
254
+ *
255
+ * "Only login/register/OTP" was the wrong scope: a newsletter popup, a
256
+ * quick-view's add-to-cart and a change-password form all finish inside a
257
+ * modal too. What differs is not the mechanism but the SUCCESS STATE — a
258
+ * sign-in leaves nothing to read, while "thanks for subscribing" is the point
259
+ * — so every one of them carries the same switch and only the auth forms
260
+ * default it on.
261
+ */
262
+ describe('any block that finishes inside a modal', () => {
263
+ const newsletterInDrawer = (properties: Record<string, unknown>) => {
264
+ const view = render(
265
+ <ThemeModalMount modal={mkModal('newsletter-modal')}>
266
+ <NewsletterFormBlock
267
+ block={
268
+ block({
269
+ id: 'news-1',
270
+ type: 'newsletter-form',
271
+ label: 'Newsletter',
272
+ properties
273
+ }) as Block
274
+ }
275
+ placeholderId="placeholder-1"
276
+ sectionId="section-1"
277
+ currentBreakpoint="desktop"
278
+ />
279
+ </ThemeModalMount>
280
+ );
281
+
282
+ act(() => {
283
+ openModal('newsletter-modal');
284
+ });
285
+
286
+ return view;
287
+ };
288
+
289
+ const subscribeWith = (container: HTMLElement) => {
290
+ fireEvent.change(container.querySelector('input[type="email"]')!, {
291
+ target: { value: 'ada@example.com' }
292
+ });
293
+ fireEvent.submit(container.querySelector('form')!);
294
+ };
295
+
296
+ beforeEach(() => {
297
+ subscribe.mockClear();
298
+ clearAutoOpenRecord('newsletter-modal');
299
+ });
300
+
301
+ it('keeps the popup open by default, so the thank-you can be read', async () => {
302
+ const { container } = newsletterInDrawer({});
303
+
304
+ subscribeWith(container);
305
+
306
+ await waitFor(() => expect(subscribe).toHaveBeenCalled());
307
+ expect(isModalOpen('newsletter-modal')).toBe(true);
308
+ });
309
+
310
+ it('closes it when the designer asked for that', async () => {
311
+ const { container } = newsletterInDrawer({ closeHostModalOnSuccess: true });
312
+
313
+ subscribeWith(container);
314
+
315
+ await waitFor(() => expect(isModalOpen('newsletter-modal')).toBe(false));
316
+ });
317
+ });
318
+
319
+ describe('the sign-in trio, with the switch turned off', () => {
320
+ it('leaves the drawer open — the default is a default, not a rule', async () => {
321
+ const { container } = openDrawerWithLogin({
322
+ closeHostModalOnSuccess: false
323
+ });
324
+
325
+ submit(container);
326
+
327
+ await waitFor(() => expect(refresh).toHaveBeenCalled());
328
+ expect(isModalOpen('auth-drawer')).toBe(true);
329
+ });
330
+ });
331
+
332
+ describe('what must NOT close the surface', () => {
333
+ it('a refused sign-in leaves the drawer up, with the message in it', async () => {
334
+ signIn.mockImplementationOnce(() =>
335
+ Promise.resolve({
336
+ error: 'CredentialsSignin',
337
+ code: JSON.stringify([
338
+ { type: 'non_field_errors', data: ['E-mail or password is wrong.'] }
339
+ ])
340
+ })
341
+ );
342
+
343
+ const { container } = openDrawerWithLogin();
344
+
345
+ submit(container);
346
+
347
+ await waitFor(() =>
348
+ expect(
349
+ container.textContent?.includes('E-mail or password is wrong.')
350
+ ).toBe(true)
351
+ );
352
+ // Closing here would take the error away with the form that has to be
353
+ // corrected — the one case where an auto-close is plainly wrong.
354
+ expect(isModalOpen('auth-drawer')).toBe(true);
355
+ });
356
+
357
+ it('closes the host even when a legacy composition asked for all modals', async () => {
358
+ const { container } = openDrawerWithLogin({ closeModalsOnSuccess: true });
359
+
360
+ submit(container);
361
+
362
+ // Both run: the wide close is a superset, so an already-published drawer
363
+ // is not stuck on it. (The narrow one then finds nothing to do.)
364
+ await waitFor(() => expect(getOpenModalIds()).toHaveLength(0));
365
+ });
366
+ });
@@ -33,6 +33,8 @@ jest.mock('next-auth/react', () => ({
33
33
  }));
34
34
 
35
35
  jest.mock('next/navigation', () => ({
36
+ usePathname: () => '/',
37
+ useSearchParams: () => new URLSearchParams(),
36
38
  useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
37
39
  }));
38
40
 
@@ -34,6 +34,8 @@ jest.mock('next-auth/react', () => ({
34
34
  }));
35
35
 
36
36
  jest.mock('next/navigation', () => ({
37
+ usePathname: () => '/',
38
+ useSearchParams: () => new URLSearchParams(),
37
39
  useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
38
40
  }));
39
41
 
@@ -1,7 +1,16 @@
1
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
1
+ import { act, 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';
5
14
  import { AUTH_CONSENT_NAMES } from '../blocks/auth-consent-block';
6
15
  import {
7
16
  __resetAuthFlowBusForTests,
@@ -36,6 +45,8 @@ const push = jest.fn();
36
45
  const refresh = jest.fn();
37
46
 
38
47
  jest.mock('next/navigation', () => ({
48
+ usePathname: () => '/',
49
+ useSearchParams: () => new URLSearchParams(),
39
50
  useRouter: () => ({ push, refresh, replace: jest.fn() })
40
51
  }));
41
52
 
@@ -121,6 +132,8 @@ beforeEach(() => {
121
132
  push.mockClear();
122
133
  refresh.mockClear();
123
134
  __resetAuthFlowBusForTests();
135
+ __resetOverlayBusForTests();
136
+ __resetModalPreviewForTests();
124
137
  });
125
138
 
126
139
  describe('a composed register form', () => {
@@ -158,7 +171,10 @@ describe('a composed register form', () => {
158
171
  await waitFor(() => expect(signIn).toHaveBeenCalled());
159
172
 
160
173
  expect(submitted()).toMatchObject({
161
- formType: 'registration',
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',
162
178
  first_name: 'Ada',
163
179
  last_name: 'Lovelace',
164
180
  email: 'ada@example.com',
@@ -337,7 +353,7 @@ describe('the SMS handoff from a composed registration', () => {
337
353
  // the sign-up used — password included, which is why it never outlives the
338
354
  // journey (see `registerAuthFlowMount`).
339
355
  expect(pending.payload).toMatchObject({
340
- formType: 'registration',
356
+ formType: 'register',
341
357
  email: 'ada@example.com',
342
358
  is_allowed: true
343
359
  });
@@ -621,7 +637,7 @@ describe('the body a composition posts', () => {
621
637
  fireEvent.submit(container.querySelector('form')!);
622
638
 
623
639
  await waitFor(() => expect(signIn).toHaveBeenCalled());
624
- expect(submitted()).toMatchObject({ formType: 'registration' });
640
+ expect(submitted()).toMatchObject({ formType: 'register' });
625
641
  // False, not the string: a truthy `redirect` navigates away instead of
626
642
  // answering, and the form would never see its own errors again.
627
643
  expect(submitted().redirect).toBe(false);
@@ -737,3 +753,236 @@ describe('a field the design has taken off this breakpoint', () => {
737
753
  expect(submitted()).not.toHaveProperty('phone');
738
754
  });
739
755
  });
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
+ });