@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,313 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import ForgotPasswordBlock from '../blocks/forgot-password-block';
5
+ import {
6
+ __resetAuthFlowBusForTests,
7
+ AuthFlowIdContext,
8
+ getAuthFlowState
9
+ } from '../auth/auth-flow-bus';
10
+ import { Block } from '../theme-block';
11
+
12
+ /**
13
+ * The password reset, element by element.
14
+ *
15
+ * Its pieces used to be style PARTS — a brand could recolour the input but not
16
+ * put a sentence above it explaining what the mail does, move the message out
17
+ * from under the button, or replace the CTA with the drawer's pinned one. Now
18
+ * they are blocks, the way a login form's fields already were: an `auth-field`
19
+ * for the address, a Form Error block for the message, an ordinary Button for
20
+ * the submit.
21
+ *
22
+ * What must NOT change is what reaches commerce: a composed form and a fixed
23
+ * one request the reset for the same address. These pin that, the two states of
24
+ * the journey, and the refusal that keeps the canvas from mailing a real
25
+ * shopper.
26
+ */
27
+
28
+ let mutationState = { isLoading: false, isSuccess: false };
29
+ const forgotPassword = jest.fn(() => ({ unwrap: () => Promise.resolve({}) }));
30
+
31
+ /**
32
+ * A catch-all, not just the one hook this block calls.
33
+ *
34
+ * `jest.config`'s moduleNameMapper collapses every `@akinon/next/data/client/*`
35
+ * onto ONE mock file, so replacing the module for `user` replaces it for `misc`
36
+ * too — and a composed form draws a child Button, which reaches for a
37
+ * subscription hook of its own. Overriding a single export leaves every other
38
+ * one undefined and the child crashes.
39
+ */
40
+ jest.mock('@akinon/next/data/client/user', () => {
41
+ const handler = {
42
+ get(_target: unknown, prop: string) {
43
+ if (prop === '__esModule') return true;
44
+ if (typeof prop !== 'string') return undefined;
45
+ if (prop === 'useForgotPasswordMutation') {
46
+ return () => [forgotPassword, mutationState];
47
+ }
48
+ if (prop.endsWith('Mutation')) {
49
+ return () => [
50
+ jest.fn(() => ({ unwrap: () => Promise.resolve({}) })),
51
+ { isLoading: false, isSuccess: false, isError: false }
52
+ ];
53
+ }
54
+ if (prop.endsWith('Query')) {
55
+ return () => ({
56
+ data: undefined,
57
+ isLoading: false,
58
+ isSuccess: true,
59
+ refetch: jest.fn()
60
+ });
61
+ }
62
+ return () => undefined;
63
+ }
64
+ };
65
+
66
+ return new Proxy({}, handler);
67
+ });
68
+
69
+ jest.mock('next/navigation', () => ({
70
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
71
+ }));
72
+
73
+ const block = (overrides: Partial<Block>): Block =>
74
+ ({
75
+ id: 'blk',
76
+ type: 'text',
77
+ label: 'Block',
78
+ value: '',
79
+ order: 0,
80
+ hidden: false,
81
+ properties: {},
82
+ styles: {},
83
+ ...overrides
84
+ } as Block);
85
+
86
+ /** Exactly what `forgotPasswordDefaultChildren()` seeds in the editor. */
87
+ const SEEDED: Block[] = [
88
+ block({
89
+ id: 'field-email',
90
+ type: 'auth-field',
91
+ label: 'E-mail Field',
92
+ properties: { fieldType: 'email', label: 'E-mail' },
93
+ order: 0
94
+ }),
95
+ block({
96
+ id: 'form-error',
97
+ type: 'auth-error',
98
+ label: 'Form Error',
99
+ order: 1
100
+ }),
101
+ block({
102
+ id: 'submit',
103
+ type: 'button',
104
+ label: 'Submit Button',
105
+ properties: {
106
+ text: 'Send reset link',
107
+ tag: 'button',
108
+ type: 'submit'
109
+ },
110
+ order: 2
111
+ })
112
+ ];
113
+
114
+ const renderComposed = (
115
+ properties: Record<string, unknown> = {},
116
+ children: Block[] = SEEDED,
117
+ options: { isDesigner?: boolean } = {}
118
+ ) =>
119
+ render(
120
+ <AuthFlowIdContext.Provider value="auth">
121
+ <ForgotPasswordBlock
122
+ block={
123
+ block({
124
+ id: 'reset-1',
125
+ type: 'forgot-password',
126
+ label: 'Forgot Password',
127
+ // The seeded Button is the submit, so the built-in one is off —
128
+ // which is what the editor sets when it drops the form.
129
+ properties: { showSubmit: false, ...properties },
130
+ blocks: children
131
+ }) as Block
132
+ }
133
+ placeholderId="placeholder-1"
134
+ sectionId="section-1"
135
+ currentBreakpoint="desktop"
136
+ isDesigner={options.isDesigner ?? false}
137
+ />
138
+ </AuthFlowIdContext.Provider>
139
+ );
140
+
141
+ const form = () => document.querySelector('form') as HTMLFormElement;
142
+ const emailInput = () =>
143
+ document.querySelector('input[name="email"]') as HTMLInputElement;
144
+
145
+ beforeEach(() => {
146
+ __resetAuthFlowBusForTests();
147
+ mutationState = { isLoading: false, isSuccess: false };
148
+ forgotPassword.mockClear();
149
+ forgotPassword.mockImplementation(() => ({
150
+ unwrap: () => Promise.resolve({})
151
+ }));
152
+ });
153
+
154
+ describe('a composed password reset', () => {
155
+ it('draws its children instead of its own row', () => {
156
+ const { container } = renderComposed();
157
+
158
+ expect(
159
+ container.querySelector('[data-forgot-password-composed]')
160
+ ).not.toBeNull();
161
+ // The child Button, not the block's built-in submit.
162
+ expect(container.querySelector('[data-forgot-password-submit]')).toBeNull();
163
+ expect(screen.getByText('Send reset link')).toBeTruthy();
164
+ });
165
+
166
+ it('requests the reset for the address a child field collected', async () => {
167
+ renderComposed();
168
+
169
+ fireEvent.change(emailInput(), { target: { value: 'ada@example.com' } });
170
+ fireEvent.submit(form());
171
+
172
+ await waitFor(() => expect(forgotPassword).toHaveBeenCalled());
173
+ // The same body the fixed row sends — the address is read off the form
174
+ // rather than from state no child writes to.
175
+ expect(forgotPassword).toHaveBeenCalledWith({ email: 'ada@example.com' });
176
+ });
177
+
178
+ it('trims the address rather than sending what a stray space typed', async () => {
179
+ renderComposed();
180
+
181
+ fireEvent.change(emailInput(), { target: { value: ' ada@example.com ' } });
182
+ fireEvent.submit(form());
183
+
184
+ await waitFor(() => expect(forgotPassword).toHaveBeenCalled());
185
+ expect(forgotPassword).toHaveBeenCalledWith({ email: 'ada@example.com' });
186
+ });
187
+
188
+ it('says which field is missing instead of raising a browser bubble', async () => {
189
+ renderComposed({ requiredMessage: 'Bu alan zorunludur.' });
190
+
191
+ fireEvent.submit(form());
192
+
193
+ await waitFor(() =>
194
+ expect(screen.getByText('Bu alan zorunludur.')).toBeTruthy()
195
+ );
196
+ expect(forgotPassword).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it('never mails a real shopper from the canvas', () => {
200
+ renderComposed({}, SEEDED, { isDesigner: true });
201
+
202
+ fireEvent.change(emailInput(), { target: { value: 'ada@example.com' } });
203
+ fireEvent.submit(form());
204
+
205
+ expect(forgotPassword).not.toHaveBeenCalled();
206
+ });
207
+
208
+ it('still renders its fixed row for a composition that has no children', () => {
209
+ const { container } = renderComposed({ showSubmit: true }, []);
210
+
211
+ expect(
212
+ container.querySelector('[data-forgot-password-composed]')
213
+ ).toBeNull();
214
+ expect(
215
+ container.querySelector('[data-forgot-password-field="email"]')
216
+ ).not.toBeNull();
217
+ expect(
218
+ container.querySelector('[data-forgot-password-submit]')
219
+ ).not.toBeNull();
220
+ });
221
+ });
222
+
223
+ describe('where the reset form puts its message', () => {
224
+ const failing = () =>
225
+ forgotPassword.mockImplementation(() => ({
226
+ unwrap: () => Promise.reject(new Error('refused'))
227
+ }));
228
+
229
+ it('draws it in the Form Error block, and stands its own node down', async () => {
230
+ failing();
231
+ const { container } = renderComposed({ errorLabel: 'Gönderilemedi.' });
232
+
233
+ fireEvent.change(emailInput(), { target: { value: 'ada@example.com' } });
234
+ fireEvent.submit(form());
235
+
236
+ await waitFor(() =>
237
+ expect(container.querySelector('[data-auth-error]')).not.toBeNull()
238
+ );
239
+ expect(screen.getByText('Gönderilemedi.')).toBeTruthy();
240
+ // Exactly one message: the fallback node is what the block exists to
241
+ // replace, so it must not draw beside it.
242
+ expect(container.querySelector('[data-forgot-password-error]')).toBeNull();
243
+ });
244
+
245
+ it('falls back to its own node for a composition that placed none', async () => {
246
+ failing();
247
+ const { container } = renderComposed({ errorLabel: 'Gönderilemedi.' }, [
248
+ SEEDED[0],
249
+ SEEDED[2]
250
+ ]);
251
+
252
+ fireEvent.change(emailInput(), { target: { value: 'ada@example.com' } });
253
+ fireEvent.submit(form());
254
+
255
+ await waitFor(() =>
256
+ expect(
257
+ container.querySelector('[data-forgot-password-error]')
258
+ ).not.toBeNull()
259
+ );
260
+ expect(screen.getByText('Gönderilemedi.')).toBeTruthy();
261
+ });
262
+ });
263
+
264
+ describe('the state after the mail is away', () => {
265
+ it('moves the flow to the step the design authored for it', async () => {
266
+ renderComposed({ successStepTarget: 'sent' });
267
+
268
+ fireEvent.change(emailInput(), { target: { value: 'ada@example.com' } });
269
+ fireEvent.submit(form());
270
+
271
+ // A step is more than a message and a link could be: the brand's own copy,
272
+ // illustration and CTA, authored out of ordinary blocks.
273
+ await waitFor(() => expect(getAuthFlowState('auth')?.active).toBe('sent'));
274
+ });
275
+
276
+ it('names the flow outright when the target spells one', async () => {
277
+ renderComposed({ successStepTarget: 'drawer:sent' });
278
+
279
+ fireEvent.change(emailInput(), { target: { value: 'ada@example.com' } });
280
+ fireEvent.submit(form());
281
+
282
+ await waitFor(() =>
283
+ expect(getAuthFlowState('drawer')?.active).toBe('sent')
284
+ );
285
+ });
286
+
287
+ it('leaves the flow alone for a form that named no step', async () => {
288
+ renderComposed();
289
+
290
+ fireEvent.change(emailInput(), { target: { value: 'ada@example.com' } });
291
+ fireEvent.submit(form());
292
+
293
+ await waitFor(() => expect(forgotPassword).toHaveBeenCalled());
294
+ // Nothing was navigated: the block shows its own success copy instead,
295
+ // which is what every published reset form does.
296
+ expect(getAuthFlowState('auth')).toBeNull();
297
+ });
298
+
299
+ it('shows its own success copy once commerce has answered', () => {
300
+ mutationState = { isLoading: false, isSuccess: true };
301
+
302
+ const { container } = renderComposed({
303
+ successLabel: 'Posta yolda.'
304
+ });
305
+
306
+ expect(
307
+ container.querySelector('[data-forgot-password-state="success"]')
308
+ ).not.toBeNull();
309
+ expect(screen.getByText('Posta yolda.')).toBeTruthy();
310
+ // The form is gone, so the same address cannot be submitted twice.
311
+ expect(document.querySelector('form')).toBeNull();
312
+ });
313
+ });