@akinon/pz-theme 2.0.83-beta.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 -7
  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,487 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import OtpFormBlock from '../blocks/otp-form-block';
5
+ import {
6
+ __resetAuthFlowBusForTests,
7
+ getPendingOtp,
8
+ setPendingOtp
9
+ } from '../auth/auth-flow-bus';
10
+ import { Block } from '../theme-block';
11
+
12
+ /**
13
+ * The OTP form, element by element.
14
+ *
15
+ * Its pieces used to be style PARTS — a designer could recolour the code boxes
16
+ * but not move them, put copy between them, or replace the resend with the
17
+ * brand's own control. Now they are blocks, the way an auth form's fields
18
+ * became blocks: an `otp-phase` per half of the journey, `otp-code-input` for
19
+ * the boxes, an Auth Field for the number, ordinary Buttons for the CTA, the
20
+ * resend and the change-number.
21
+ *
22
+ * What has to hold: the phase decides which children draw, the boxes write into
23
+ * the ONE code the form verifies, the two action-driven controls reach the form
24
+ * they stand in, and a form with no children still renders its fixed layout
25
+ * exactly as every published one does.
26
+ */
27
+
28
+ const signIn = jest.fn(() => Promise.resolve({ error: null, url: '/' }));
29
+
30
+ jest.mock('next-auth/react', () => ({
31
+ signIn: (...args: unknown[]) => signIn(...args),
32
+ useSession: () => ({ data: null, status: 'unauthenticated' })
33
+ }));
34
+
35
+ jest.mock('next/navigation', () => ({
36
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
37
+ }));
38
+
39
+ let nextId = 0;
40
+ const blockOf = (
41
+ type: string,
42
+ properties: Record<string, unknown> = {},
43
+ blocks?: Block[]
44
+ ): Block => ({
45
+ id: `${type}-${(nextId += 1)}`,
46
+ type,
47
+ label: type,
48
+ properties,
49
+ styles: {},
50
+ order: 0,
51
+ hidden: false,
52
+ ...(blocks ? { blocks } : {})
53
+ });
54
+
55
+ const ordered = (blocks: Block[]): Block[] =>
56
+ blocks.map((child, index) => ({ ...child, order: index }));
57
+
58
+ const phonePhase = () =>
59
+ blockOf(
60
+ 'otp-phase',
61
+ { phaseKey: 'phone' },
62
+ ordered([
63
+ blockOf('text', {}),
64
+ blockOf('auth-field', { fieldType: 'phone', label: 'Phone' }),
65
+ blockOf('button', { text: 'Send code', tag: 'button', type: 'submit' })
66
+ ])
67
+ );
68
+
69
+ const codePhase = () =>
70
+ blockOf(
71
+ 'otp-phase',
72
+ { phaseKey: 'code' },
73
+ ordered([
74
+ blockOf('otp-code-input', {}),
75
+ blockOf('button', { text: 'Resend code', actionType: 'otp-resend' }),
76
+ blockOf('button', {
77
+ text: 'Use another number',
78
+ actionType: 'otp-change-number'
79
+ }),
80
+ blockOf('button', { text: 'Verify', tag: 'button', type: 'submit' })
81
+ ])
82
+ );
83
+
84
+ const renderComposed = (
85
+ properties: Record<string, unknown> = {},
86
+ children: Block[] = [phonePhase(), codePhase()]
87
+ ) =>
88
+ render(
89
+ <OtpFormBlock
90
+ block={{
91
+ ...blockOf('otp-form', { collectPhone: true, ...properties }),
92
+ blocks: ordered(children)
93
+ }}
94
+ placeholderId="placeholder-1"
95
+ sectionId="section-1"
96
+ currentBreakpoint="desktop"
97
+ />
98
+ );
99
+
100
+ const form = () => document.querySelector('form') as HTMLFormElement;
101
+ const codeBoxes = () =>
102
+ Array.from(
103
+ document.querySelectorAll<HTMLInputElement>('[data-otp-code] input')
104
+ );
105
+
106
+ beforeEach(() => {
107
+ __resetAuthFlowBusForTests();
108
+ signIn.mockClear();
109
+ });
110
+
111
+ /** The send is a request; the phase only turns once it has answered. */
112
+ const submitAndSettle = async () => {
113
+ await act(async () => {
114
+ fireEvent.submit(form());
115
+ });
116
+ };
117
+
118
+ describe('a composed OTP form', () => {
119
+ it('draws only the phase it is in', () => {
120
+ renderComposed();
121
+
122
+ expect(document.querySelector('input[name="phone"]')).toBeTruthy();
123
+ expect(screen.getByText('Send code')).toBeTruthy();
124
+ // The code half is not on screen yet.
125
+ expect(codeBoxes()).toHaveLength(0);
126
+ expect(screen.queryByText('Verify')).toBeNull();
127
+ });
128
+
129
+ it('fills the block wrapper, and so does each phase', () => {
130
+ renderComposed();
131
+
132
+ expect(form().classList).toContain('w-full');
133
+ expect(
134
+ (document.querySelector('[data-otp-phase]') as HTMLElement).classList
135
+ ).toContain('w-full');
136
+ });
137
+
138
+ it('sends the code for the number a child field collected', async () => {
139
+ renderComposed();
140
+
141
+ fireEvent.input(document.querySelector('input[name="phone"]')!, {
142
+ target: { value: '555 111 22 33' }
143
+ });
144
+ await submitAndSettle();
145
+
146
+ // Bare digits, which is what commerce validates the length of — and the
147
+ // journey has moved on to the code it just texted.
148
+ expect(getPendingOtp()?.phone).toBe('5551112233');
149
+ expect(codeBoxes()).toHaveLength(6);
150
+ });
151
+
152
+ it('says which field is missing instead of raising a browser bubble', async () => {
153
+ renderComposed({ requiredMessage: 'Telefon zorunludur.' });
154
+
155
+ await submitAndSettle();
156
+
157
+ expect(form().noValidate).toBe(true);
158
+ expect(screen.getByText('Telefon zorunludur.')).toBeTruthy();
159
+ // Nothing was sent, so the journey stays where it was.
160
+ expect(getPendingOtp()).toBeNull();
161
+ expect(codeBoxes()).toHaveLength(0);
162
+ });
163
+
164
+ it('shows the code half once a verification is pending', () => {
165
+ setPendingOtp({
166
+ kind: 'login',
167
+ phone: '5551112233',
168
+ providerId: 'default'
169
+ });
170
+ renderComposed();
171
+
172
+ expect(codeBoxes()).toHaveLength(6);
173
+ expect(screen.getByText('Verify')).toBeTruthy();
174
+ expect(document.querySelector('input[name="phone"]')).toBeNull();
175
+ });
176
+
177
+ it('gathers what the boxes type into one code and verifies it', () => {
178
+ setPendingOtp({
179
+ kind: 'login',
180
+ phone: '5551112233',
181
+ providerId: 'default'
182
+ });
183
+ renderComposed();
184
+
185
+ // Typing walks the caret, so every digit goes into the first empty box.
186
+ '123456'.split('').forEach((digit) => {
187
+ fireEvent.change(codeBoxes().find((box) => box.value === '')!, {
188
+ target: { value: digit }
189
+ });
190
+ });
191
+
192
+ expect(
193
+ codeBoxes()
194
+ .map((box) => box.value)
195
+ .join('')
196
+ ).toBe('123456');
197
+
198
+ fireEvent.submit(form());
199
+
200
+ expect(signIn).toHaveBeenCalledTimes(1);
201
+ expect(signIn.mock.calls[0][1]).toMatchObject({ code: '123456' });
202
+ });
203
+
204
+ it('names what is missing when the code is half typed', () => {
205
+ setPendingOtp({
206
+ kind: 'login',
207
+ phone: '5551112233',
208
+ providerId: 'default'
209
+ });
210
+ renderComposed({ codeIncompleteMessage: '{length} haneli kodu girin.' });
211
+
212
+ fireEvent.change(codeBoxes()[0], { target: { value: '1' } });
213
+ fireEvent.submit(form());
214
+
215
+ // A composed CTA cannot disable itself, so silence would read as dead.
216
+ expect(screen.getByText('6 haneli kodu girin.')).toBeTruthy();
217
+ expect(signIn).not.toHaveBeenCalled();
218
+ });
219
+
220
+ it('takes the shopper back to the number through an action', () => {
221
+ setPendingOtp({
222
+ kind: 'login',
223
+ phone: '5551112233',
224
+ providerId: 'default'
225
+ });
226
+ renderComposed();
227
+
228
+ fireEvent.click(screen.getByText('Use another number'));
229
+
230
+ // Back to the phone half, and the pending verification is forgotten.
231
+ expect(document.querySelector('input[name="phone"]')).toBeTruthy();
232
+ expect(codeBoxes()).toHaveLength(0);
233
+ });
234
+
235
+ it('locks the resend while the countdown runs', () => {
236
+ setPendingOtp({
237
+ kind: 'login',
238
+ phone: '5551112233',
239
+ providerId: 'default'
240
+ });
241
+ renderComposed({ timerSeconds: 60 });
242
+
243
+ // A Button block with no `tag` renders an anchor; what matters is that the
244
+ // control it renders says it cannot be used yet.
245
+ const resend = screen.getByText('Resend code').closest('[aria-disabled]');
246
+
247
+ expect(resend).toBeTruthy();
248
+ });
249
+
250
+ it('keeps drawing shared chrome in both halves', () => {
251
+ const banner = blockOf('text', {});
252
+ setPendingOtp({
253
+ kind: 'login',
254
+ phone: '5551112233',
255
+ providerId: 'default'
256
+ });
257
+ renderComposed({}, [banner, phonePhase(), codePhase()]);
258
+
259
+ // A child that is not a phase belongs to the whole form.
260
+ expect(document.querySelector('[data-otp-code]')).toBeTruthy();
261
+ expect(document.querySelectorAll('[data-otp-phase]')).toHaveLength(1);
262
+ });
263
+
264
+ it('still renders the fixed layout with no children', () => {
265
+ renderComposed({}, []);
266
+
267
+ expect(document.querySelector('input[name="phone"]')).toBeTruthy();
268
+ expect(
269
+ document.querySelector('[data-otp-form-submit="send"]')
270
+ ).toBeTruthy();
271
+ });
272
+ });
273
+
274
+ describe('correcting a code that is already partly typed', () => {
275
+ const typeInto = (index: number, value: string) =>
276
+ fireEvent.change(codeBoxes()[index], { target: { value } });
277
+
278
+ const shown = () =>
279
+ codeBoxes()
280
+ .map((box) => box.value || '_')
281
+ .join('');
282
+
283
+ beforeEach(() => {
284
+ setPendingOtp({
285
+ kind: 'login',
286
+ phone: '5551112233',
287
+ providerId: 'default'
288
+ });
289
+ });
290
+
291
+ it('writes the digit into the box it was typed in', () => {
292
+ renderComposed();
293
+
294
+ '123456'.split('').forEach((digit, index) => typeInto(index, digit));
295
+ expect(shown()).toBe('123456');
296
+
297
+ // The shopper sees the second digit is wrong and clicks it. The box is
298
+ // CONTROLLED, so what arrives is its own digit plus the new one — which,
299
+ // appended to the end of a full code, used to be capped away entirely: the
300
+ // keystroke did nothing at all.
301
+ typeInto(1, '29');
302
+ expect(shown()).toBe('193456');
303
+ });
304
+
305
+ it('does not push a correction onto the end of a half-typed code', () => {
306
+ renderComposed();
307
+
308
+ '123'.split('').forEach((digit, index) => typeInto(index, digit));
309
+ expect(shown()).toBe('123___');
310
+
311
+ // Both digits used to land at the end, turning "123" into "12319".
312
+ typeInto(0, '19');
313
+ expect(shown()).toBe('923___');
314
+ });
315
+
316
+ it('empties the box Backspace was pressed in, not the last one', () => {
317
+ renderComposed();
318
+
319
+ '123456'.split('').forEach((digit, index) => typeInto(index, digit));
320
+
321
+ fireEvent.keyDown(codeBoxes()[1], { key: 'Backspace' });
322
+ expect(shown()).toBe('1_3456');
323
+
324
+ // Pressed again in a box that is already empty, it steps back.
325
+ fireEvent.keyDown(codeBoxes()[1], { key: 'Backspace' });
326
+ expect(shown()).toBe('__3456');
327
+ });
328
+
329
+ it('will not verify a code with a hole in it', () => {
330
+ renderComposed({ codeIncompleteMessage: '{length} haneli kodu girin.' });
331
+
332
+ '123456'.split('').forEach((digit, index) => typeInto(index, digit));
333
+ fireEvent.keyDown(codeBoxes()[2], { key: 'Backspace' });
334
+
335
+ fireEvent.submit(form());
336
+
337
+ // Six characters long, five digits: a gap is not a finished code.
338
+ expect(screen.getByText('6 haneli kodu girin.')).toBeTruthy();
339
+ expect(signIn).not.toHaveBeenCalled();
340
+ });
341
+ });
342
+
343
+ describe('the journey a verification belongs to', () => {
344
+ it('ignores a verification parked by another journey', () => {
345
+ // One page, one slot: a header's phone-login drawer used to open straight
346
+ // on the code screen for a registration abandoned elsewhere on the page —
347
+ // and re-post it, password and all, under a code typed for something else.
348
+ setPendingOtp({
349
+ kind: 'register',
350
+ phone: '5551234567',
351
+ providerId: 'default',
352
+ scope: 'checkout',
353
+ payload: { formType: 'registration', password: 'hunter2' }
354
+ });
355
+
356
+ renderComposed();
357
+
358
+ // This form stands outside every flow, so the record is not its business:
359
+ // it starts where it always did, at the number.
360
+ expect(
361
+ document.querySelector('[data-otp-form-phase="phone"]')
362
+ ).toBeTruthy();
363
+ expect(codeBoxes()).toHaveLength(0);
364
+ });
365
+
366
+ it('finishes a verification parked by its own journey', () => {
367
+ setPendingOtp({
368
+ kind: 'register',
369
+ phone: '5551234567',
370
+ providerId: 'default',
371
+ payload: { formType: 'registration', password: 'hunter2' }
372
+ });
373
+
374
+ renderComposed();
375
+
376
+ expect(codeBoxes()).toHaveLength(6);
377
+ });
378
+
379
+ it('refuses to throw a registration away for a new number', () => {
380
+ // `pending.payload` is the ONLY copy of the sign-up. The fixed layout has
381
+ // always hidden this control during a registration; a composed one puts it
382
+ // on a Button the author placed, so the refusal belongs on the method.
383
+ setPendingOtp({
384
+ kind: 'register',
385
+ phone: '5551234567',
386
+ providerId: 'default',
387
+ payload: { formType: 'registration', password: 'hunter2' }
388
+ });
389
+
390
+ renderComposed();
391
+
392
+ // The control says it is unavailable rather than vanishing from a design
393
+ // that placed it, the way the resend does during its cooldown.
394
+ expect(
395
+ screen.getByText('Use another number').closest('[aria-disabled]')
396
+ ).toBeTruthy();
397
+
398
+ fireEvent.click(screen.getByText('Use another number'));
399
+
400
+ expect(getPendingOtp()).not.toBeNull();
401
+ expect(codeBoxes()).toHaveLength(6);
402
+ });
403
+ });
404
+
405
+ describe('asking for another code', () => {
406
+ const REGISTER_PENDING = {
407
+ kind: 'register' as const,
408
+ phone: '5551234567',
409
+ providerId: 'default',
410
+ payload: { formType: 'registration', password: 'hunter2' }
411
+ };
412
+
413
+ /** Run the cooldown out, which is the only thing that unlocks the control. */
414
+ const waitOutTheCountdown = async (seconds: number) => {
415
+ await act(async () => {
416
+ jest.advanceTimersByTime(seconds * 1000);
417
+ });
418
+ };
419
+
420
+ beforeEach(() => {
421
+ jest.useFakeTimers();
422
+ });
423
+
424
+ afterEach(() => {
425
+ jest.useRealTimers();
426
+ });
427
+
428
+ it('texts the shopper once however fast the control is clicked twice', async () => {
429
+ // The countdown only restarts once the request has ANSWERED, so between the
430
+ // click and the response the control still read as available — and a second
431
+ // click there is a second SMS, billed twice and counted as abuse by some
432
+ // backends. The window is a whole round trip, not a race.
433
+ let release: (value: unknown) => void = () => undefined;
434
+ signIn.mockImplementationOnce(
435
+ () =>
436
+ new Promise((resolve) => {
437
+ release = resolve;
438
+ }) as never
439
+ );
440
+
441
+ setPendingOtp(REGISTER_PENDING);
442
+ renderComposed({ timerSeconds: 10 });
443
+ await waitOutTheCountdown(10);
444
+
445
+ const resend = screen.getByText('Resend code');
446
+ fireEvent.click(resend);
447
+ fireEvent.click(resend);
448
+
449
+ expect(signIn).toHaveBeenCalledTimes(1);
450
+
451
+ await act(async () => {
452
+ release({ error: JSON.stringify([{ type: 'otp' }]) });
453
+ });
454
+ });
455
+
456
+ it('says so when the resend is refused, instead of restarting the wait', async () => {
457
+ // `signIn` RESOLVES on a refusal rather than throwing, so the response has
458
+ // to be read. A rate limit used to be swallowed as the expected challenge:
459
+ // the typed digits were wiped, the ring started again, and the shopper
460
+ // waited out a lockout for an SMS that was never sent.
461
+ setPendingOtp(REGISTER_PENDING);
462
+ renderComposed({ timerSeconds: 10 });
463
+ await waitOutTheCountdown(10);
464
+
465
+ fireEvent.change(codeBoxes()[0], { target: { value: '1' } });
466
+
467
+ signIn.mockImplementationOnce(
468
+ () =>
469
+ Promise.resolve({
470
+ error: 'CredentialsSignin',
471
+ code: JSON.stringify([{ type: 'limit', data: 'Too many attempts.' }])
472
+ }) as never
473
+ );
474
+
475
+ await act(async () => {
476
+ fireEvent.click(screen.getByText('Resend code'));
477
+ });
478
+
479
+ expect(screen.getByText('Too many attempts.')).toBeTruthy();
480
+ // What they had already typed is still there, and the control has not
481
+ // locked itself for another cooldown.
482
+ expect(codeBoxes()[0].value).toBe('1');
483
+ expect(
484
+ screen.getByText('Resend code').closest('[aria-disabled]')
485
+ ).toBeNull();
486
+ });
487
+ });