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

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 (63) hide show
  1. package/CHANGELOG.md +31 -33
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-flow-block.test.tsx +211 -0
  4. package/src/__tests__/auth-flow-bus.test.ts +290 -0
  5. package/src/__tests__/auth-form-error-block.test.tsx +326 -0
  6. package/src/__tests__/auth-form-validation.test.tsx +204 -0
  7. package/src/__tests__/auth-step-title.test.tsx +202 -0
  8. package/src/__tests__/detached-submit-state.test.tsx +216 -0
  9. package/src/__tests__/forgot-password-composed.test.tsx +313 -0
  10. package/src/__tests__/otp-form-composed.test.tsx +487 -0
  11. package/src/__tests__/password-reset-step.test.tsx +269 -0
  12. package/src/__tests__/register-form-composed.test.tsx +739 -0
  13. package/src/__tests__/registry-parity.test.ts +9 -0
  14. package/src/auth/auth-field-errors.tsx +151 -0
  15. package/src/auth/auth-flow-bus.ts +415 -0
  16. package/src/auth/form-error-context.tsx +55 -0
  17. package/src/auth/form-submit-bus.ts +93 -0
  18. package/src/auth/otp-form-context.tsx +80 -0
  19. package/src/blocks/__tests__/button-block.test.tsx +55 -0
  20. package/src/blocks/__tests__/link-block.test.tsx +48 -30
  21. package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
  22. package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
  23. package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
  24. package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
  25. package/src/blocks/__tests__/text-block.test.tsx +37 -3
  26. package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
  27. package/src/blocks/auth-consent-block.tsx +214 -0
  28. package/src/blocks/auth-error-block.tsx +82 -0
  29. package/src/blocks/auth-field-block.tsx +270 -0
  30. package/src/blocks/auth-flow-block.tsx +251 -0
  31. package/src/blocks/auth-form-block.tsx +391 -47
  32. package/src/blocks/auth-step-block.tsx +81 -0
  33. package/src/blocks/auth-step-title-block.tsx +81 -0
  34. package/src/blocks/block-renderer-registry.tsx +18 -0
  35. package/src/blocks/button-block.tsx +91 -13
  36. package/src/blocks/forgot-password-block.tsx +318 -24
  37. package/src/blocks/link-block.tsx +22 -84
  38. package/src/blocks/otp-code-input-block.tsx +180 -0
  39. package/src/blocks/otp-form-block.tsx +1092 -0
  40. package/src/blocks/otp-phase-block.tsx +71 -0
  41. package/src/blocks/register-form-block.tsx +541 -74
  42. package/src/blocks/text-block.tsx +17 -40
  43. package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
  44. package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
  45. package/src/hooks/use-block-action.ts +87 -4
  46. package/src/hooks/use-overlay.ts +60 -9
  47. package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
  48. package/src/overlays/__tests__/modal-registry.test.ts +6 -1
  49. package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
  50. package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
  51. package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
  52. package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
  53. package/src/overlays/modal-preview-host.tsx +39 -2
  54. package/src/overlays/modal-registry.ts +344 -4
  55. package/src/overlays/overlay-surface.tsx +249 -19
  56. package/src/overlays/overlay-tokens.ts +94 -0
  57. package/src/overlays/theme-modal-mount.tsx +34 -4
  58. package/src/theme-block.tsx +6 -0
  59. package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
  60. package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
  61. package/src/utils/index.ts +107 -6
  62. package/src/utils/iterator-utils.ts +39 -2
  63. package/src/utils/part-styles.ts +4 -0
@@ -0,0 +1,739 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import RegisterFormBlock from '../blocks/register-form-block';
5
+ import { AUTH_CONSENT_NAMES } from '../blocks/auth-consent-block';
6
+ import {
7
+ __resetAuthFlowBusForTests,
8
+ getAuthFlowState,
9
+ getPendingOtp
10
+ } from '../auth/auth-flow-bus';
11
+ import { Block } from '../theme-block';
12
+
13
+ /**
14
+ * A registration whose rows are blocks.
15
+ *
16
+ * The login form composes: an `auth-field` per input, arranged and dressed like
17
+ * any other block. The register form did not, so a composition could rewrite
18
+ * its e-mail row but had to take the form's own consent row — the one row a
19
+ * legal team actually rewrites — and could not reorder, relabel or restyle any
20
+ * of it.
21
+ *
22
+ * What must NOT change is the body that reaches commerce: a composed form and a
23
+ * fixed one register the same shopper the same way, ticks turned into the flags
24
+ * commerce names individually. These cases pin exactly that, plus the two
25
+ * refusals (no agreement, no submit in the canvas) and the SMS handoff.
26
+ */
27
+
28
+ const signIn = jest.fn(() => Promise.resolve({ error: null, url: '/account' }));
29
+
30
+ jest.mock('next-auth/react', () => ({
31
+ signIn: (...args: unknown[]) => signIn(...args),
32
+ useSession: () => ({ data: null, status: 'unauthenticated' })
33
+ }));
34
+
35
+ const push = jest.fn();
36
+ const refresh = jest.fn();
37
+
38
+ jest.mock('next/navigation', () => ({
39
+ useRouter: () => ({ push, refresh, replace: jest.fn() })
40
+ }));
41
+
42
+ const block = (overrides: Partial<Block>): Block =>
43
+ ({
44
+ id: 'blk',
45
+ type: 'text',
46
+ label: 'Block',
47
+ value: '',
48
+ order: 0,
49
+ hidden: false,
50
+ properties: {},
51
+ styles: {},
52
+ ...overrides
53
+ }) as Block;
54
+
55
+ const field = (fieldType: string, order: number): Block =>
56
+ block({
57
+ id: `field-${fieldType}`,
58
+ type: 'auth-field',
59
+ label: fieldType,
60
+ properties: { fieldType },
61
+ order
62
+ });
63
+
64
+ const consent = (
65
+ consentType: 'legal' | 'marketing',
66
+ order: number,
67
+ properties: Record<string, unknown> = {}
68
+ ): Block =>
69
+ block({
70
+ id: `consent-${consentType}`,
71
+ type: 'auth-consent',
72
+ label: consentType,
73
+ properties: { consentType, ...properties },
74
+ order
75
+ });
76
+
77
+ const renderComposed = (children: Block[], properties = {}) =>
78
+ render(
79
+ <RegisterFormBlock
80
+ block={
81
+ block({
82
+ id: 'register-1',
83
+ type: 'register-form',
84
+ label: 'Register Form',
85
+ properties,
86
+ blocks: children
87
+ }) as Block
88
+ }
89
+ placeholderId="placeholder-1"
90
+ sectionId="section-1"
91
+ currentBreakpoint="desktop"
92
+ />
93
+ );
94
+
95
+ const COMPOSED = [
96
+ field('first_name', 0),
97
+ field('last_name', 1),
98
+ field('email', 2),
99
+ field('password', 3),
100
+ field('phone', 4),
101
+ consent('legal', 5),
102
+ consent('marketing', 6)
103
+ ];
104
+
105
+ const fill = (container: HTMLElement, values: Record<string, string>) => {
106
+ Object.entries(values).forEach(([name, value]) => {
107
+ const input = container.querySelector(
108
+ `input[name="${name}"]`
109
+ ) as HTMLInputElement;
110
+ fireEvent.change(input, { target: { value } });
111
+ });
112
+ };
113
+
114
+ const tick = (container: HTMLElement, name: string) =>
115
+ fireEvent.click(container.querySelector(`input[name="${name}"]`)!);
116
+
117
+ const submitted = () => signIn.mock.calls[0][1] as Record<string, unknown>;
118
+
119
+ beforeEach(() => {
120
+ signIn.mockClear();
121
+ push.mockClear();
122
+ refresh.mockClear();
123
+ __resetAuthFlowBusForTests();
124
+ });
125
+
126
+ describe('a composed register form', () => {
127
+ it('draws its children instead of its own rows', () => {
128
+ const { container } = renderComposed(COMPOSED);
129
+
130
+ expect(
131
+ container.querySelector('[data-register-form-composed]')
132
+ ).not.toBeNull();
133
+ // The fixed rows are the thing a composition replaces, so none of them may
134
+ // also be on the page — a shopper filling in two e-mail inputs is the bug
135
+ // this asserts against.
136
+ expect(
137
+ container.querySelectorAll('[data-register-form-field]')
138
+ ).toHaveLength(0);
139
+ expect(container.querySelectorAll('input[name="email"]')).toHaveLength(1);
140
+ expect(container.querySelectorAll('[data-auth-consent]')).toHaveLength(2);
141
+ });
142
+
143
+ it('posts the same body a fixed form posts, ticks turned into flags', async () => {
144
+ const { container } = renderComposed(COMPOSED);
145
+
146
+ fill(container, {
147
+ first_name: 'Ada',
148
+ last_name: 'Lovelace',
149
+ email: 'ada@example.com',
150
+ password: 'hunter2',
151
+ phone: '(555) 123 45 67'
152
+ });
153
+ tick(container, AUTH_CONSENT_NAMES.legal);
154
+ tick(container, AUTH_CONSENT_NAMES.marketing);
155
+
156
+ fireEvent.submit(container.querySelector('form')!);
157
+
158
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
159
+
160
+ expect(submitted()).toMatchObject({
161
+ formType: 'registration',
162
+ first_name: 'Ada',
163
+ last_name: 'Lovelace',
164
+ email: 'ada@example.com',
165
+ password: 'hunter2',
166
+ // Bare digits: the mask a brand renders around the input would fail
167
+ // commerce's length check.
168
+ phone: '5551234567',
169
+ is_allowed: true,
170
+ confirm: true,
171
+ email_allowed: true,
172
+ sms_allowed: true,
173
+ call_allowed: true,
174
+ whatsapp_allowed: true,
175
+ permissions: 'on'
176
+ });
177
+ });
178
+
179
+ it('sends the marketing flags off, and no permissions key at all, when that tick is left alone', async () => {
180
+ const { container } = renderComposed(COMPOSED);
181
+
182
+ fill(container, {
183
+ first_name: 'Ada',
184
+ last_name: 'Lovelace',
185
+ email: 'ada@example.com',
186
+ password: 'hunter2',
187
+ phone: '5551234567'
188
+ });
189
+ tick(container, AUTH_CONSENT_NAMES.legal);
190
+
191
+ fireEvent.submit(container.querySelector('form')!);
192
+
193
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
194
+
195
+ const body = submitted();
196
+
197
+ expect(body).toMatchObject({
198
+ is_allowed: true,
199
+ email_allowed: false,
200
+ sms_allowed: false,
201
+ call_allowed: false,
202
+ whatsapp_allowed: false
203
+ });
204
+ // Absent rather than falsy: the brand's own view deletes the key.
205
+ expect(body).not.toHaveProperty('permissions');
206
+ // And the raw checkbox entries must not travel — `is_allowed: 'on'` is not
207
+ // what commerce reads.
208
+ expect(body.is_allowed).not.toBe('on');
209
+ });
210
+
211
+ it('refuses to register anyone who has not agreed, and says which sentence they missed', async () => {
212
+ const { container } = renderComposed(COMPOSED, {
213
+ consentRequiredLabel: 'Üyelik sözleşmesini kabul etmelisiniz.'
214
+ });
215
+
216
+ fill(container, {
217
+ first_name: 'Ada',
218
+ last_name: 'Lovelace',
219
+ email: 'ada@example.com',
220
+ password: 'hunter2'
221
+ });
222
+
223
+ fireEvent.submit(container.querySelector('form')!);
224
+
225
+ await waitFor(() =>
226
+ expect(
227
+ screen.getByText('Üyelik sözleşmesini kabul etmelisiniz.')
228
+ ).toBeInTheDocument()
229
+ );
230
+ expect(signIn).not.toHaveBeenCalled();
231
+ });
232
+
233
+ it('never submits from the canvas, where it would create a real account', () => {
234
+ const { container } = render(
235
+ <RegisterFormBlock
236
+ block={
237
+ block({
238
+ id: 'register-1',
239
+ type: 'register-form',
240
+ label: 'Register Form',
241
+ blocks: COMPOSED
242
+ }) as Block
243
+ }
244
+ placeholderId="placeholder-1"
245
+ sectionId="section-1"
246
+ currentBreakpoint="desktop"
247
+ isDesigner
248
+ />
249
+ );
250
+
251
+ fireEvent.submit(container.querySelector('form')!);
252
+
253
+ expect(signIn).not.toHaveBeenCalled();
254
+ });
255
+
256
+ it('still renders its fixed rows for a composition that has no children', () => {
257
+ const { container } = renderComposed([]);
258
+
259
+ expect(container.querySelector('[data-register-form-composed]')).toBeNull();
260
+ expect(
261
+ container.querySelectorAll('[data-register-form-field]').length
262
+ ).toBeGreaterThan(0);
263
+ });
264
+ });
265
+
266
+ describe('the consent block on its own terms', () => {
267
+ it('requires the legal tick and leaves the marketing one optional', () => {
268
+ const { container } = renderComposed([
269
+ consent('legal', 0),
270
+ consent('marketing', 1)
271
+ ]);
272
+
273
+ expect(
274
+ container.querySelector(`input[name="${AUTH_CONSENT_NAMES.legal}"]`)
275
+ ).toBeRequired();
276
+ expect(
277
+ container.querySelector(`input[name="${AUTH_CONSENT_NAMES.marketing}"]`)
278
+ ).not.toBeRequired();
279
+ });
280
+
281
+ it('lets a brand whose agreement is not mandatory say so', () => {
282
+ const { container } = renderComposed([
283
+ consent('legal', 0, { required: false })
284
+ ]);
285
+
286
+ expect(
287
+ container.querySelector(`input[name="${AUTH_CONSENT_NAMES.legal}"]`)
288
+ ).not.toBeRequired();
289
+ });
290
+
291
+ it('carries the sentence the author wrote', () => {
292
+ const { container } = renderComposed([
293
+ consent('legal', 0, { text: 'Kuralları okudum ve kabul ediyorum.' })
294
+ ]);
295
+
296
+ expect(container).toHaveTextContent('Kuralları okudum ve kabul ediyorum.');
297
+ });
298
+ });
299
+
300
+ describe('the SMS handoff from a composed registration', () => {
301
+ /** Commerce accepts the sign-up and answers with an `otp` challenge. */
302
+ const challenge = () =>
303
+ signIn.mockImplementationOnce(() =>
304
+ Promise.resolve({
305
+ error: 'CredentialsSignin',
306
+ code: JSON.stringify([{ type: 'otp' }]),
307
+ url: null
308
+ })
309
+ );
310
+
311
+ const completeAndSubmit = (container: HTMLElement) => {
312
+ fill(container, {
313
+ first_name: 'Ada',
314
+ last_name: 'Lovelace',
315
+ email: 'ada@example.com',
316
+ password: 'hunter2',
317
+ phone: '(555) 123 45 67'
318
+ });
319
+ tick(container, AUTH_CONSENT_NAMES.legal);
320
+ fireEvent.submit(container.querySelector('form')!);
321
+ };
322
+
323
+ it('parks the whole attempt on the bus so an OTP block can finish it', async () => {
324
+ challenge();
325
+
326
+ const { container } = renderComposed(COMPOSED);
327
+ completeAndSubmit(container);
328
+
329
+ await waitFor(() => expect(getPendingOtp()).not.toBeNull());
330
+
331
+ const pending = getPendingOtp()!;
332
+
333
+ expect(pending.kind).toBe('register');
334
+ // Bare digits, because this is the number the code is sent to.
335
+ expect(pending.phone).toBe('5551234567');
336
+ // The payload is re-submitted with the code, so it has to be the same body
337
+ // the sign-up used — password included, which is why it never outlives the
338
+ // journey (see `registerAuthFlowMount`).
339
+ expect(pending.payload).toMatchObject({
340
+ formType: 'registration',
341
+ email: 'ada@example.com',
342
+ is_allowed: true
343
+ });
344
+ });
345
+
346
+ it('moves the flow to the step holding the verification, when one was named', async () => {
347
+ challenge();
348
+
349
+ const { container } = renderComposed(COMPOSED, {
350
+ otpStepTarget: 'auth:verify'
351
+ });
352
+ completeAndSubmit(container);
353
+
354
+ await waitFor(() =>
355
+ expect(getAuthFlowState('auth')?.active).toBe('verify')
356
+ );
357
+ // The challenge is a handoff, not a failure: nothing is written into the
358
+ // form's own error line.
359
+ expect(container.querySelector('[data-register-form-error]')).toBeNull();
360
+ });
361
+ });
362
+
363
+ describe("the answers the brand's own design asks for", () => {
364
+ const DESIGNED = [
365
+ field('gender', 0),
366
+ field('email', 1),
367
+ field('first_name', 2),
368
+ field('last_name', 3),
369
+ field('phone', 4),
370
+ field('date_of_birth', 5),
371
+ field('password', 6),
372
+ consent('legal', 7)
373
+ ];
374
+
375
+ it('collects a gender as one of the two values commerce stores', async () => {
376
+ const { container } = renderComposed(DESIGNED);
377
+
378
+ const select = container.querySelector(
379
+ 'select[name="gender"]'
380
+ ) as HTMLSelectElement;
381
+
382
+ // A select rather than a text box: the account page reads these strings
383
+ // back, so a composition must not be able to invent a third one.
384
+ expect(select).not.toBeNull();
385
+ expect(Array.from(select.options).map((option) => option.value)).toEqual([
386
+ '',
387
+ 'female',
388
+ 'male'
389
+ ]);
390
+
391
+ fireEvent.change(select, { target: { value: 'female' } });
392
+ fill(container, {
393
+ email: 'ada@example.com',
394
+ first_name: 'Ada',
395
+ last_name: 'Lovelace',
396
+ phone: '5551234567',
397
+ date_of_birth: '1815-12-10',
398
+ password: 'hunter2'
399
+ });
400
+ tick(container, AUTH_CONSENT_NAMES.legal);
401
+
402
+ fireEvent.submit(container.querySelector('form')!);
403
+
404
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
405
+
406
+ expect(submitted()).toMatchObject({
407
+ gender: 'female',
408
+ // The native control's own value format, which is already the one
409
+ // commerce keeps — nothing reformats it on the way out.
410
+ date_of_birth: '1815-12-10'
411
+ });
412
+ });
413
+
414
+ it('leaves the two optional answers optional', () => {
415
+ const { container } = renderComposed([
416
+ field('gender', 0),
417
+ field('date_of_birth', 1)
418
+ ]);
419
+
420
+ // Required is the field's own toggle and defaults to on; what matters here
421
+ // is that a shopper CAN be asked for these without being forced, which is
422
+ // what the seeded composition does.
423
+ expect(container.querySelector('select[name="gender"]')).not.toBeNull();
424
+ expect(
425
+ container.querySelector('input[name="date_of_birth"]')
426
+ ).toHaveAttribute('type', 'date');
427
+ });
428
+
429
+ it('lets a brand caption the two values in its own language', () => {
430
+ const { container } = renderComposed([
431
+ block({
432
+ id: 'field-gender',
433
+ type: 'auth-field',
434
+ properties: {
435
+ fieldType: 'gender',
436
+ femaleLabel: 'Kadın',
437
+ maleLabel: 'Erkek',
438
+ placeholder: 'Cinsiyet'
439
+ },
440
+ order: 0
441
+ })
442
+ ]);
443
+
444
+ const options = Array.from(
445
+ container.querySelectorAll('select[name="gender"] option')
446
+ ).map((option) => option.textContent);
447
+
448
+ expect(options).toEqual(['Cinsiyet', 'Kadın', 'Erkek']);
449
+ });
450
+ });
451
+
452
+ describe('a field or a tick the list has never heard of', () => {
453
+ const customField = (
454
+ name: string,
455
+ order: number,
456
+ properties: Record<string, unknown> = {}
457
+ ) =>
458
+ block({
459
+ id: `field-${name || 'unnamed'}`,
460
+ type: 'auth-field',
461
+ properties: { fieldType: 'custom', name, ...properties },
462
+ order
463
+ });
464
+
465
+ const customConsent = (
466
+ name: string,
467
+ order: number,
468
+ properties: Record<string, unknown> = {}
469
+ ) =>
470
+ block({
471
+ id: `consent-${name || 'unnamed'}`,
472
+ type: 'auth-consent',
473
+ properties: { consentType: 'custom', name, ...properties },
474
+ order
475
+ });
476
+
477
+ it('submits a custom field under the name the author typed', async () => {
478
+ const { container } = renderComposed([
479
+ field('email', 0),
480
+ field('password', 1),
481
+ customField('tckn', 2, { label: 'National ID', inputType: 'number' }),
482
+ consent('legal', 3)
483
+ ]);
484
+
485
+ const input = container.querySelector('input[name="tckn"]');
486
+ expect(input).toHaveAttribute('type', 'number');
487
+
488
+ fill(container, {
489
+ email: 'ada@example.com',
490
+ password: 'hunter2',
491
+ tckn: '12345678901'
492
+ });
493
+ tick(container, AUTH_CONSENT_NAMES.legal);
494
+ fireEvent.submit(container.querySelector('form')!);
495
+
496
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
497
+ // Straight through: the form forwards every named control it finds, which
498
+ // is what makes a new field an editor job instead of a release.
499
+ expect(submitted()).toMatchObject({ tckn: '12345678901' });
500
+ });
501
+
502
+ it('draws nothing for a custom field nobody has named', () => {
503
+ const { container } = renderComposed([customField('', 0)]);
504
+
505
+ // An input with no name collects into the void; a shopper must not be given
506
+ // one to fill in.
507
+ expect(container.querySelectorAll('input')).toHaveLength(0);
508
+ });
509
+
510
+ it('sends a custom tick as the value the author chose, and omits it when unticked', async () => {
511
+ const children = [
512
+ field('email', 0),
513
+ field('password', 1),
514
+ consent('legal', 2),
515
+ customConsent('kvkk_approved', 3, { tickedValue: 'on' })
516
+ ];
517
+
518
+ const first = renderComposed(children);
519
+ fill(first.container, { email: 'a@b.com', password: 'hunter2' });
520
+ tick(first.container, AUTH_CONSENT_NAMES.legal);
521
+ fireEvent.submit(first.container.querySelector('form')!);
522
+
523
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
524
+ // Unticked: absent altogether, exactly like the marketing flag's key.
525
+ expect(submitted()).not.toHaveProperty('kvkk_approved');
526
+
527
+ signIn.mockClear();
528
+ first.unmount();
529
+
530
+ const second = renderComposed(children);
531
+ fill(second.container, { email: 'a@b.com', password: 'hunter2' });
532
+ tick(second.container, AUTH_CONSENT_NAMES.legal);
533
+ tick(second.container, 'kvkk_approved');
534
+ fireEvent.submit(second.container.querySelector('form')!);
535
+
536
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
537
+ expect(submitted()).toMatchObject({ kvkk_approved: 'on' });
538
+ });
539
+
540
+ it('defaults a custom tick to true, which is what an agreement is stored as', () => {
541
+ const { container } = renderComposed([customConsent('kvkk_approved', 0)]);
542
+
543
+ expect(
544
+ container.querySelector('input[name="kvkk_approved"]')
545
+ ).toHaveAttribute('value', 'true');
546
+ });
547
+
548
+ it('draws nothing for a custom tick nobody has named', () => {
549
+ const { container } = renderComposed([customConsent('', 0)]);
550
+
551
+ expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(
552
+ 0
553
+ );
554
+ });
555
+ });
556
+
557
+ describe('the body a composition posts', () => {
558
+ const customField = (
559
+ name: string,
560
+ order: number,
561
+ properties: Record<string, unknown> = {}
562
+ ) =>
563
+ block({
564
+ id: `field-custom-${name || 'unnamed'}`,
565
+ type: 'auth-field',
566
+ properties: { fieldType: 'custom', name, ...properties },
567
+ order
568
+ });
569
+
570
+ it('leaves an optional answer out rather than posting an empty one', async () => {
571
+ // The two answers the design frames a sign-up with are optional, so a
572
+ // shopper skipping them is the ordinary case — and a gender left on its
573
+ // empty option is not a choice commerce can store. The fixed rows never
574
+ // sent these keys, and the form already deletes `permissions` rather than
575
+ // send a falsy one; this is the same rule.
576
+ const optional = (fieldType: string, order: number) =>
577
+ block({
578
+ id: `field-${fieldType}`,
579
+ type: 'auth-field',
580
+ properties: { fieldType, required: false },
581
+ order
582
+ });
583
+
584
+ const { container } = renderComposed([
585
+ optional('gender', 0),
586
+ field('email', 1),
587
+ field('password', 2),
588
+ optional('date_of_birth', 3),
589
+ consent('legal', 4)
590
+ ]);
591
+
592
+ fill(container, { email: 'ada@example.com', password: 'hunter2' });
593
+ tick(container, AUTH_CONSENT_NAMES.legal);
594
+ fireEvent.submit(container.querySelector('form')!);
595
+
596
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
597
+ expect(submitted()).not.toHaveProperty('gender');
598
+ expect(submitted()).not.toHaveProperty('date_of_birth');
599
+ expect(submitted()).toMatchObject({ email: 'ada@example.com' });
600
+ });
601
+
602
+ it('refuses a field named after the protocol instead of letting it through', async () => {
603
+ // `formType` picks the commerce endpoint and `redirect` decides whether
604
+ // next-auth hands back a response at all — a field named after either is
605
+ // not an extra answer, it is a different request.
606
+ const { container } = renderComposed([
607
+ field('email', 0),
608
+ field('password', 1),
609
+ customField('formType', 2, { label: 'Form type' }),
610
+ customField('redirect', 3, { label: 'Redirect' }),
611
+ consent('legal', 4)
612
+ ]);
613
+
614
+ fill(container, {
615
+ email: 'ada@example.com',
616
+ password: 'hunter2',
617
+ formType: 'oops',
618
+ redirect: 'yes'
619
+ });
620
+ tick(container, AUTH_CONSENT_NAMES.legal);
621
+ fireEvent.submit(container.querySelector('form')!);
622
+
623
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
624
+ expect(submitted()).toMatchObject({ formType: 'registration' });
625
+ // False, not the string: a truthy `redirect` navigates away instead of
626
+ // answering, and the form would never see its own errors again.
627
+ expect(submitted().redirect).toBe(false);
628
+ expect(submitted().callbackUrl).toBe('/');
629
+ });
630
+
631
+ it('keeps what the shopper typed when a field name is duplicated', async () => {
632
+ // A row copied with Duplicate, or a breakpoint twin: two controls answer to
633
+ // one name and only one of them is filled in. `collectAuthFieldErrors`
634
+ // already resolves that duplicate first-one-wins; the body used to take the
635
+ // LAST, which is typically the empty one.
636
+ const { container } = renderComposed([
637
+ field('email', 0),
638
+ field('password', 1),
639
+ customField('email', 2, { label: 'E-mail again', required: false }),
640
+ consent('legal', 3)
641
+ ]);
642
+
643
+ const emails = container.querySelectorAll('input[name="email"]');
644
+ expect(emails).toHaveLength(2);
645
+
646
+ fireEvent.change(emails[0], { target: { value: 'ada@example.com' } });
647
+ fill(container, { password: 'hunter2' });
648
+ tick(container, AUTH_CONSENT_NAMES.legal);
649
+ fireEvent.submit(container.querySelector('form')!);
650
+
651
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
652
+ expect(submitted()).toMatchObject({ email: 'ada@example.com' });
653
+ });
654
+ });
655
+
656
+ describe('an agreement the brand does not insist on', () => {
657
+ it('registers without the tick when the row says it is optional', async () => {
658
+ // The editor offers "turn it off for an optional tick" and the block
659
+ // promises a brand in a market where the agreement is not mandatory can say
660
+ // so. The refusal used to run whatever the row said, so the toggle changed
661
+ // which message appeared and nothing else.
662
+ const { container } = renderComposed([
663
+ field('email', 0),
664
+ field('password', 1),
665
+ consent('legal', 2, { required: false })
666
+ ]);
667
+
668
+ fill(container, { email: 'ada@example.com', password: 'hunter2' });
669
+ fireEvent.submit(container.querySelector('form')!);
670
+
671
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
672
+ // The shopper's actual answer travels, rather than a yes nobody gave.
673
+ expect(submitted()).toMatchObject({ is_allowed: false, confirm: false });
674
+ });
675
+
676
+ it('still refuses when the row is the mandatory one it is by default', async () => {
677
+ const { container } = renderComposed(
678
+ [field('email', 0), field('password', 1), consent('legal', 2)],
679
+ { consentRequiredLabel: 'Üyelik sözleşmesini kabul etmelisiniz.' }
680
+ );
681
+
682
+ fill(container, { email: 'ada@example.com', password: 'hunter2' });
683
+ fireEvent.submit(container.querySelector('form')!);
684
+
685
+ // A required tick is caught as the row's own missing answer, so the
686
+ // sentence lands under it rather than at the foot of the form.
687
+ await waitFor(() =>
688
+ expect(
689
+ screen.getByText('Üyelik sözleşmesini kabul etmelisiniz.')
690
+ ).toBeInTheDocument()
691
+ );
692
+ expect(signIn).not.toHaveBeenCalled();
693
+ });
694
+
695
+ it('registers a composition that does not ask the question at all', async () => {
696
+ // No consent row means the brand is not asking — which is what a childless
697
+ // form does too, and it registers. The refusal used to fire anyway, telling
698
+ // the shopper to accept an agreement that was nowhere on screen and leaving
699
+ // them no way to ever submit.
700
+ const { container } = renderComposed([
701
+ field('email', 0),
702
+ field('password', 1)
703
+ ]);
704
+
705
+ fill(container, { email: 'ada@example.com', password: 'hunter2' });
706
+ fireEvent.submit(container.querySelector('form')!);
707
+
708
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
709
+ expect(submitted()).toMatchObject({ is_allowed: true, confirm: true });
710
+ });
711
+ });
712
+
713
+ describe('a field the design has taken off this breakpoint', () => {
714
+ it('does not hold the form hostage to a control nobody can see', async () => {
715
+ // The editor's Visibility setting is CSS: it writes `display: none` onto
716
+ // the block wrapper and leaves the control in the form. `willValidate` does
717
+ // not care about CSS, so a required field hidden on mobile refused every
718
+ // submit there — with the explanation drawn inside the hidden wrapper, and
719
+ // `focus()` a no-op on an element that is not rendered.
720
+ const { container } = renderComposed([
721
+ field('email', 0),
722
+ field('password', 1),
723
+ field('phone', 2),
724
+ consent('legal', 3)
725
+ ]);
726
+
727
+ const phone = container.querySelector(
728
+ 'input[name="phone"]'
729
+ ) as HTMLInputElement;
730
+ (phone.closest('label') as HTMLElement).style.display = 'none';
731
+
732
+ fill(container, { email: 'ada@example.com', password: 'hunter2' });
733
+ tick(container, AUTH_CONSENT_NAMES.legal);
734
+ fireEvent.submit(container.querySelector('form')!);
735
+
736
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
737
+ expect(submitted()).not.toHaveProperty('phone');
738
+ });
739
+ });