@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,232 @@
1
+ import {
2
+ authFormMessage,
3
+ readAuthError,
4
+ readCommerceError,
5
+ splitByKnownFields
6
+ } from '../auth/read-auth-error';
7
+
8
+ /**
9
+ * What a commerce refusal says, and where it belongs on screen.
10
+ *
11
+ * The bug this replaces: all three private copies kept a message only when it
12
+ * was a string, and commerce sends field errors as `{ name, value: string[] }`.
13
+ * So "this e-mail is already registered" extracted to nothing, the empty result
14
+ * fell through to `String(error)`, and the shopper read the raw JSON — while the
15
+ * designer's authored fallback sentence never fired, because that JSON is a
16
+ * non-empty string.
17
+ */
18
+
19
+ const fieldErrors = (
20
+ entries: { name: string; value: unknown }[]
21
+ ): string => JSON.stringify([{ type: 'field_errors', data: entries }]);
22
+
23
+ describe('a field-level refusal', () => {
24
+ it('reads the array shape commerce actually sends, per field', () => {
25
+ const reading = readAuthError(
26
+ fieldErrors([
27
+ { name: 'email', value: ['A user with this e-mail already exists.'] },
28
+ { name: 'password', value: ['This password is too common.'] }
29
+ ])
30
+ );
31
+
32
+ expect(reading.fieldMessages).toEqual({
33
+ email: 'A user with this e-mail already exists.',
34
+ password: 'This password is too common.'
35
+ });
36
+ // Nothing is left over for the form-level slot, which is the whole point:
37
+ // a second generic line above the inputs would only add noise.
38
+ expect(reading.formMessage).toBe('');
39
+ });
40
+
41
+ it('keeps every sentence when a field has more than one', () => {
42
+ const reading = readAuthError(
43
+ fieldErrors([
44
+ { name: 'password', value: ['Too short.', 'Too common.'] }
45
+ ])
46
+ );
47
+
48
+ expect(reading.fieldMessages.password).toBe('Too short. Too common.');
49
+ });
50
+
51
+ it('reads a bare string and a re-encoded array the same way', () => {
52
+ expect(
53
+ readAuthError(fieldErrors([{ name: 'email', value: 'Invalid.' }]))
54
+ .fieldMessages.email
55
+ ).toBe('Invalid.');
56
+
57
+ // Some brand middleware re-encodes the array on its way through.
58
+ expect(
59
+ readAuthError(
60
+ fieldErrors([{ name: 'email', value: '["Invalid.","Try again."]' }])
61
+ ).fieldMessages.email
62
+ ).toBe('Invalid. Try again.');
63
+ });
64
+
65
+ it('never hangs a non-field message under a field', () => {
66
+ const reading = readAuthError(
67
+ fieldErrors([
68
+ { name: 'non_field_errors', value: ['Too many attempts.'] }
69
+ ])
70
+ );
71
+
72
+ expect(reading.fieldMessages).toEqual({});
73
+ expect(reading.formMessage).toBe('Too many attempts.');
74
+ });
75
+ });
76
+
77
+ describe('a form-level refusal', () => {
78
+ it('reads non_field_errors', () => {
79
+ expect(
80
+ readAuthError(
81
+ JSON.stringify([
82
+ { type: 'non_field_errors', data: ['E-mail or password is wrong.'] }
83
+ ])
84
+ ).formMessage
85
+ ).toBe('E-mail or password is wrong.');
86
+ });
87
+
88
+ it('keeps an unknown error type legible rather than dropping it', () => {
89
+ expect(
90
+ readAuthError(
91
+ JSON.stringify([{ type: 'brand_specific', data: 'Account locked.' }])
92
+ ).formMessage
93
+ ).toBe('Account locked.');
94
+ });
95
+
96
+ it('falls back to the raw detail only when nothing legible was said', () => {
97
+ const opaque = JSON.stringify([{ type: 'field_errors', data: [] }]);
98
+
99
+ expect(readAuthError(opaque).formMessage).toBe(opaque);
100
+ expect(readAuthError('CredentialsSignin').formMessage).toBe(
101
+ 'CredentialsSignin'
102
+ );
103
+ expect(readAuthError('').formMessage).toBe('');
104
+ });
105
+ });
106
+
107
+ describe('the two challenges that carry no words', () => {
108
+ it('reports the SMS handoff separately, so a form can park its payload', () => {
109
+ const reading = readAuthError(JSON.stringify([{ type: 'otp' }]), {
110
+ otp: 'We sent you a code.'
111
+ });
112
+
113
+ expect(reading.hasOtpChallenge).toBe(true);
114
+ expect(reading.formMessage).toBe('We sent you a code.');
115
+ });
116
+
117
+ it('names a captcha escalation instead of printing its JSON', () => {
118
+ const reading = readAuthError(JSON.stringify([{ type: 'captcha' }]), {
119
+ captcha: 'Please complete the security check.'
120
+ });
121
+
122
+ expect(reading.hasCaptchaChallenge).toBe(true);
123
+ expect(reading.formMessage).toBe('Please complete the security check.');
124
+ // Without authored copy it is still a challenge, not an unreadable blob:
125
+ // the caller's own fallback sentence takes over.
126
+ expect(readAuthError(JSON.stringify([{ type: 'captcha' }])).formMessage).toBe(
127
+ ''
128
+ );
129
+ });
130
+ });
131
+
132
+ describe('the raw commerce body an RTK Query mutation rejects with', () => {
133
+ it('splits field keys from the form-level ones', () => {
134
+ const reading = readCommerceError({
135
+ email: ['No account with this address.'],
136
+ non_field_errors: ['Try again later.']
137
+ });
138
+
139
+ expect(reading.fieldMessages).toEqual({
140
+ email: 'No account with this address.'
141
+ });
142
+ expect(reading.formMessage).toBe('Try again later.');
143
+ });
144
+
145
+ it('reads DRF-style detail and a captcha redirect', () => {
146
+ expect(readCommerceError({ detail: 'Throttled.' }).formMessage).toBe(
147
+ 'Throttled.'
148
+ );
149
+
150
+ const reading = readCommerceError(
151
+ { redirect_url: '/captcha/?next=/login' },
152
+ { captcha: 'Security check needed.' }
153
+ );
154
+
155
+ expect(reading.hasCaptchaChallenge).toBe(true);
156
+ expect(reading.formMessage).toBe('Security check needed.');
157
+ });
158
+ });
159
+
160
+ describe('attribution against the form on screen', () => {
161
+ const formWith = (names: string[]): HTMLFormElement => {
162
+ const form = document.createElement('form');
163
+
164
+ names.forEach((name) => {
165
+ const input = document.createElement('input');
166
+ input.name = name;
167
+ form.appendChild(input);
168
+ });
169
+
170
+ return form;
171
+ };
172
+
173
+ it('moves a message for a field nobody authored up to the form', () => {
174
+ const reading = splitByKnownFields(
175
+ readAuthError(
176
+ fieldErrors([
177
+ { name: 'email', value: ['Already registered.'] },
178
+ { name: 'tckn', value: ['This is required.'] }
179
+ ])
180
+ ),
181
+ formWith(['email', 'password'])
182
+ );
183
+
184
+ expect(reading.fieldMessages).toEqual({ email: 'Already registered.' });
185
+ // Not lost: a sentence hung on a name no control submits under would be a
186
+ // sentence the shopper never sees.
187
+ expect(reading.formMessage).toBe('This is required.');
188
+ });
189
+
190
+ it('accepts a named field set, for a handler with no form to measure', () => {
191
+ const reading = splitByKnownFields(
192
+ readAuthError(
193
+ fieldErrors([
194
+ { name: 'code', value: ['Invalid code.'] },
195
+ { name: 'surname', value: ['Unexpected.'] }
196
+ ])
197
+ ),
198
+ null,
199
+ ['code', 'phone']
200
+ );
201
+
202
+ expect(reading.fieldMessages).toEqual({ code: 'Invalid code.' });
203
+ expect(reading.formMessage).toBe('Unexpected.');
204
+ });
205
+
206
+ it('leaves the reading alone when it measured nothing', () => {
207
+ const reading = readAuthError(
208
+ fieldErrors([{ name: 'email', value: ['Already registered.'] }])
209
+ );
210
+
211
+ expect(splitByKnownFields(reading, null)).toBe(reading);
212
+ });
213
+ });
214
+
215
+ describe('the sentence a form shows above its inputs', () => {
216
+ it('is the authored fallback only when nothing was legible', () => {
217
+ const unreadable = readAuthError('CredentialsSignin');
218
+ const attributed = readAuthError(
219
+ fieldErrors([{ name: 'email', value: ['Already registered.'] }])
220
+ );
221
+
222
+ expect(authFormMessage(unreadable, 'Login failed.')).toBe(
223
+ 'CredentialsSignin'
224
+ );
225
+ expect(authFormMessage(readAuthError(''), 'Login failed.')).toBe(
226
+ 'Login failed.'
227
+ );
228
+ // Every sentence found a field, so the slot stays empty rather than
229
+ // repeating a generic line the shopper does not need.
230
+ expect(authFormMessage(attributed, 'Login failed.')).toBe('');
231
+ });
232
+ });
@@ -0,0 +1,162 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import AuthFlowBlock from '../blocks/auth-flow-block';
5
+ import { __resetAuthFlowBusForTests } from '../auth/auth-flow-bus';
6
+ import { Block } from '../theme-block';
7
+
8
+ /**
9
+ * Two journeys on one page, which is what "put it in a tab, in a modal, in a
10
+ * popup or on a page" invites a brand to do.
11
+ *
12
+ * They used to share their step: every flow with nothing authored fell back to
13
+ * one name on the bus, so a header drawer and a journey in a tab were the same
14
+ * journey wearing two coats — opening the drawer on "register" moved the tab's
15
+ * journey with it, and closing the drawer reset it under the shopper.
16
+ *
17
+ * A flow with no authored name now answers to its own BLOCK, so two of them
18
+ * are two journeys. Sharing stays available and stays deliberate: give both the
19
+ * same Flow ID, which is also what lets a trigger outside a flow name it.
20
+ */
21
+
22
+ const textChild = (id: string, value: string): Block => ({
23
+ id,
24
+ type: 'text',
25
+ label: id,
26
+ value,
27
+ properties: {},
28
+ styles: {},
29
+ order: 0,
30
+ hidden: false
31
+ });
32
+
33
+ const step = (
34
+ id: string,
35
+ stepKey: string,
36
+ children: Block[],
37
+ order = 0
38
+ ): Block => ({
39
+ id,
40
+ type: 'auth-step',
41
+ label: id,
42
+ properties: { stepKey },
43
+ styles: {},
44
+ order,
45
+ hidden: false,
46
+ blocks: children
47
+ });
48
+
49
+ /** A step-changing control, composed the way the shipped arrangement does. */
50
+ const stepButton = (id: string, label: string, target: string): Block => ({
51
+ id,
52
+ type: 'button',
53
+ label,
54
+ properties: {
55
+ text: label,
56
+ tag: 'button',
57
+ type: 'button',
58
+ actionType: 'auth-step',
59
+ actionTarget: target
60
+ },
61
+ styles: {},
62
+ order: 1,
63
+ hidden: false
64
+ });
65
+
66
+ const journey = (
67
+ blockId: string,
68
+ prefix: string,
69
+ properties: Record<string, unknown> = {}
70
+ ): Block => ({
71
+ id: blockId,
72
+ type: 'auth-flow',
73
+ label: 'Auth Flow',
74
+ properties: { initialStep: 'welcome', ...properties },
75
+ styles: {},
76
+ order: 0,
77
+ hidden: false,
78
+ blocks: [
79
+ step(
80
+ `${blockId}-welcome`,
81
+ 'welcome',
82
+ [
83
+ textChild(`${blockId}-t-welcome`, `${prefix} welcome`),
84
+ stepButton(`${blockId}-go`, `${prefix} to register`, 'register')
85
+ ],
86
+ 0
87
+ ),
88
+ step(
89
+ `${blockId}-register`,
90
+ 'register',
91
+ [textChild(`${blockId}-t-register`, `${prefix} register`)],
92
+ 1
93
+ )
94
+ ]
95
+ });
96
+
97
+ const renderBoth = (properties: Record<string, unknown> = {}) =>
98
+ render(
99
+ <>
100
+ <AuthFlowBlock
101
+ block={journey('flow-drawer', 'Drawer', properties)}
102
+ placeholderId="header"
103
+ sectionId="section-modal"
104
+ isDesigner={false}
105
+ selectedBlockId={null}
106
+ />
107
+ <AuthFlowBlock
108
+ block={journey('flow-page', 'Page', properties)}
109
+ placeholderId="body"
110
+ sectionId="section-body"
111
+ isDesigner={false}
112
+ selectedBlockId={null}
113
+ />
114
+ </>
115
+ );
116
+
117
+ beforeEach(() => {
118
+ __resetAuthFlowBusForTests();
119
+ });
120
+
121
+ describe('two auth journeys on one page', () => {
122
+ it('move independently when neither names itself', () => {
123
+ renderBoth();
124
+
125
+ act(() => {
126
+ fireEvent.click(screen.getByText('Drawer to register'));
127
+ });
128
+
129
+ // The one that was clicked moved…
130
+ expect(screen.getByText('Drawer register')).toBeInTheDocument();
131
+ // …and the other is exactly where the shopper left it.
132
+ expect(screen.getByText('Page welcome')).toBeInTheDocument();
133
+ expect(screen.queryByText('Page register')).toBeNull();
134
+ });
135
+
136
+ it('still move together when the design says they are one journey', () => {
137
+ renderBoth({ flowId: 'auth' });
138
+
139
+ act(() => {
140
+ fireEvent.click(screen.getByText('Drawer to register'));
141
+ });
142
+
143
+ // Sharing is the authored case: one journey rendered twice, or a trigger
144
+ // outside the flow naming it.
145
+ expect(screen.getByText('Drawer register')).toBeInTheDocument();
146
+ expect(screen.getByText('Page register')).toBeInTheDocument();
147
+ });
148
+
149
+ it('keeps a bare step target working inside its own flow', () => {
150
+ // The shipped arrangement writes bare keys ('register', not 'auth:register')
151
+ // and resolves them against the flow the control stands in — which is what
152
+ // makes a block-scoped name invisible to a composition.
153
+ renderBoth();
154
+
155
+ act(() => {
156
+ fireEvent.click(screen.getByText('Page to register'));
157
+ });
158
+
159
+ expect(screen.getByText('Page register')).toBeInTheDocument();
160
+ expect(screen.getByText('Drawer welcome')).toBeInTheDocument();
161
+ });
162
+ });
@@ -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