@akinon/pz-theme 2.0.95-rc.0 → 2.0.95-rc.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 +34 -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,429 @@
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 ResetPasswordBlock from '../blocks/reset-password-block';
6
+ import { containsAuthSuccessBlock } from '../auth/form-success-context';
7
+ import { __resetAuthFlowBusForTests } from '../auth/auth-flow-bus';
8
+ import { Block } from '../theme-block';
9
+
10
+ /**
11
+ * Where the CONFIRMATION lands.
12
+ *
13
+ * The error half of this was solved a while ago: the form publishes its message
14
+ * and a Form Error block draws it where the design says. The success half was
15
+ * still a node the form drew itself — and in these two blocks that node
16
+ * REPLACES the whole form, so a brand could recolour "a reset link is on its
17
+ * way" through a style part and nothing else: no icon beside it, no heading
18
+ * above it, no second line, no way to move it.
19
+ *
20
+ * These cases hold the three halves of the contract that fixes it: the block
21
+ * draws the message, the built-in copy stands down, and — the part that has no
22
+ * counterpart on the error side — the composition STAYS on screen, because a
23
+ * block inside a form that has been swapped out is unmounted and would draw
24
+ * nothing at all.
25
+ */
26
+
27
+ let forgotState = { isLoading: false, isSuccess: false };
28
+ const forgotPassword = jest.fn(() => ({ unwrap: () => Promise.resolve({}) }));
29
+
30
+ const passwordReset = jest.fn();
31
+ const unwrap = jest.fn();
32
+ let resetState = { isLoading: false, isSuccess: false };
33
+
34
+ /**
35
+ * ONE mock for both forms, because `jest.config`'s moduleNameMapper collapses
36
+ * every `@akinon/next/data/client/*` onto the same file: a second `jest.mock`
37
+ * for `account` would replace this one rather than sit beside it, and whichever
38
+ * form lost would call an undefined hook.
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, forgotState];
47
+ }
48
+ if (prop === 'usePasswordResetMutation') {
49
+ return () => [
50
+ (...args: unknown[]) => {
51
+ passwordReset(...args);
52
+
53
+ return { unwrap };
54
+ },
55
+ resetState
56
+ ];
57
+ }
58
+ if (prop === 'useGetValidatePasswordResetTokenQuery') {
59
+ return () => ({
60
+ data: { validlink: true },
61
+ isLoading: false,
62
+ isError: false
63
+ });
64
+ }
65
+ if (prop.endsWith('Mutation')) {
66
+ return () => [
67
+ jest.fn(() => ({ unwrap: () => Promise.resolve({}) })),
68
+ { isLoading: false, isSuccess: false, isError: false }
69
+ ];
70
+ }
71
+ if (prop.endsWith('Query')) {
72
+ return () => ({
73
+ data: undefined,
74
+ isLoading: false,
75
+ isSuccess: true,
76
+ refetch: jest.fn()
77
+ });
78
+ }
79
+ return () => undefined;
80
+ }
81
+ };
82
+
83
+ return new Proxy({}, handler);
84
+ });
85
+
86
+ jest.mock('next/navigation', () => ({
87
+ useParams: () => ({ id: ['tok', 'abc123'] }),
88
+ usePathname: () => '/users/reset/tok/abc123',
89
+ useSearchParams: () => new URLSearchParams(),
90
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
91
+ }));
92
+
93
+ const block = (overrides: Partial<Block>): Block =>
94
+ ({
95
+ id: 'blk',
96
+ type: 'text',
97
+ label: 'Block',
98
+ value: '',
99
+ order: 0,
100
+ hidden: false,
101
+ properties: {},
102
+ styles: {},
103
+ ...overrides
104
+ }) as Block;
105
+
106
+ const successBlock = (
107
+ properties: Record<string, unknown> = {},
108
+ overrides: Partial<Block> = {}
109
+ ) =>
110
+ block({
111
+ id: 'success-row',
112
+ type: 'auth-success',
113
+ label: 'Form Success',
114
+ properties,
115
+ order: 1,
116
+ ...overrides
117
+ });
118
+
119
+ const emailField = block({
120
+ id: 'field-email',
121
+ type: 'auth-field',
122
+ label: 'E-mail Field',
123
+ properties: { fieldType: 'email', label: 'E-mail' },
124
+ order: 0
125
+ });
126
+
127
+ const cta = block({
128
+ id: 'cta',
129
+ type: 'button',
130
+ label: 'Submit Button',
131
+ properties: { text: 'Send reset link', tag: 'button', type: 'submit' },
132
+ order: 2
133
+ });
134
+
135
+ const renderForgot = (
136
+ children: Block[],
137
+ properties: Record<string, unknown> = {},
138
+ isDesigner = false
139
+ ) =>
140
+ render(
141
+ <ForgotPasswordBlock
142
+ block={
143
+ block({
144
+ id: 'forgot-1',
145
+ type: 'forgot-password',
146
+ label: 'Forgot Password',
147
+ properties: { showSubmit: false, ...properties },
148
+ blocks: children
149
+ }) as Block
150
+ }
151
+ placeholderId="placeholder-1"
152
+ sectionId="section-1"
153
+ currentBreakpoint="desktop"
154
+ isDesigner={isDesigner}
155
+ />
156
+ );
157
+
158
+ const passwordFields = [
159
+ block({
160
+ id: 'field-password',
161
+ type: 'auth-field',
162
+ label: 'Password',
163
+ properties: { fieldType: 'password', label: 'New password' },
164
+ order: 0
165
+ }),
166
+ block({
167
+ id: 'field-repeat',
168
+ type: 'auth-field',
169
+ label: 'Repeat',
170
+ properties: {
171
+ fieldType: 'custom',
172
+ name: 'repeatPassword',
173
+ inputType: 'password',
174
+ label: 'New password again'
175
+ },
176
+ order: 1
177
+ })
178
+ ];
179
+
180
+ const renderReset = (
181
+ children: Block[],
182
+ properties: Record<string, unknown> = {}
183
+ ) =>
184
+ render(
185
+ <ResetPasswordBlock
186
+ block={
187
+ block({
188
+ id: 'reset-1',
189
+ type: 'reset-password',
190
+ label: 'Reset Password',
191
+ properties,
192
+ blocks: children
193
+ }) as Block
194
+ }
195
+ placeholderId="placeholder-1"
196
+ sectionId="section-1"
197
+ currentBreakpoint="desktop"
198
+ />
199
+ );
200
+
201
+ beforeEach(() => {
202
+ __resetAuthFlowBusForTests();
203
+ forgotState = { isLoading: false, isSuccess: false };
204
+ resetState = { isLoading: false, isSuccess: false };
205
+ forgotPassword.mockClear();
206
+ passwordReset.mockClear();
207
+ unwrap.mockReset();
208
+ unwrap.mockResolvedValue(undefined);
209
+ });
210
+
211
+ describe('finding the block that draws the confirmation', () => {
212
+ it('finds one placed directly in the form', () => {
213
+ expect(containsAuthSuccessBlock([emailField, successBlock()])).toBe(true);
214
+ });
215
+
216
+ it('finds one tucked inside a group, however deep', () => {
217
+ expect(
218
+ containsAuthSuccessBlock([
219
+ block({
220
+ id: 'group',
221
+ type: 'group',
222
+ blocks: [block({ id: 'inner', type: 'group', blocks: [successBlock()] })]
223
+ })
224
+ ])
225
+ ).toBe(true);
226
+ });
227
+
228
+ it('ignores a hidden one, which draws nothing wherever it sits', () => {
229
+ expect(
230
+ containsAuthSuccessBlock([successBlock({}, { hidden: true })])
231
+ ).toBe(false);
232
+ });
233
+
234
+ it('says no for a composition with none, and for none at all', () => {
235
+ expect(containsAuthSuccessBlock([emailField])).toBe(false);
236
+ expect(containsAuthSuccessBlock(undefined)).toBe(false);
237
+ });
238
+ });
239
+
240
+ describe('a password-reset request that draws its own confirmation', () => {
241
+ it('shows the message in the block, and keeps the form it was placed in', () => {
242
+ forgotState = { isLoading: false, isSuccess: true };
243
+
244
+ const { container } = renderForgot(
245
+ [emailField, successBlock(), cta],
246
+ { successLabel: 'Bir bağlantı yolda.' }
247
+ );
248
+
249
+ expect(screen.getByText('Bir bağlantı yolda.')).toBeInTheDocument();
250
+ // Inside the block the design placed — not in a node appended by the form.
251
+ expect(
252
+ container.querySelector('[data-auth-success]')?.textContent
253
+ ).toContain('Bir bağlantı yolda.');
254
+ // And the composition is still standing, which is what makes the block
255
+ // reachable at all: replaced, it would have been unmounted.
256
+ expect(container.querySelector('form')).not.toBeNull();
257
+ expect(container.querySelector('input[name="email"]')).not.toBeNull();
258
+ });
259
+
260
+ it('draws the message where the design put it, above the CTA', () => {
261
+ forgotState = { isLoading: false, isSuccess: true };
262
+
263
+ const { container } = renderForgot([emailField, successBlock(), cta], {
264
+ successLabel: 'Bir bağlantı yolda.'
265
+ });
266
+
267
+ const message = container.querySelector('[data-auth-success]')!;
268
+ const button = screen.getByText('Send reset link');
269
+
270
+ expect(
271
+ message.compareDocumentPosition(button) &
272
+ Node.DOCUMENT_POSITION_FOLLOWING
273
+ ).toBeTruthy();
274
+ });
275
+
276
+ it('marks the form as finished so the state can be dressed', () => {
277
+ forgotState = { isLoading: false, isSuccess: true };
278
+
279
+ const { container } = renderForgot([emailField, successBlock(), cta]);
280
+
281
+ expect(
282
+ container
283
+ .querySelector('[data-forgot-password-composed]')
284
+ ?.getAttribute('data-forgot-password-state')
285
+ ).toBe('success');
286
+ });
287
+
288
+ it('keeps its send button, because pressing it again is a resend', () => {
289
+ forgotState = { isLoading: false, isSuccess: true };
290
+
291
+ const { container } = renderForgot([emailField, successBlock()], {
292
+ showSubmit: true
293
+ });
294
+
295
+ // Unlike the reset form below: the address is still valid, so a second
296
+ // submit is the "didn't arrive?" affordance rather than a guaranteed error.
297
+ expect(
298
+ container.querySelector('[data-forgot-password-submit]')
299
+ ).not.toBeNull();
300
+ });
301
+
302
+ it('draws nothing at all before the mail is away', () => {
303
+ const { container } = renderForgot([emailField, successBlock(), cta], {
304
+ successLabel: 'Bir bağlantı yolda.'
305
+ });
306
+
307
+ expect(container.querySelector('[data-auth-success]')).toBeNull();
308
+ expect(screen.queryByText('Bir bağlantı yolda.')).toBeNull();
309
+ });
310
+
311
+ it('still requests the reset the way it always did', async () => {
312
+ const { container } = renderForgot([emailField, successBlock(), cta]);
313
+
314
+ fireEvent.change(container.querySelector('input[name="email"]')!, {
315
+ target: { value: 'ada@example.com' }
316
+ });
317
+ fireEvent.submit(container.querySelector('form')!);
318
+
319
+ await waitFor(() => expect(forgotPassword).toHaveBeenCalled());
320
+ expect(forgotPassword).toHaveBeenCalledWith({ email: 'ada@example.com' });
321
+ });
322
+ });
323
+
324
+ describe('a composition that placed no such block', () => {
325
+ it('is replaced by the built-in copy, exactly as before', () => {
326
+ forgotState = { isLoading: false, isSuccess: true };
327
+
328
+ const { container } = renderForgot([emailField, cta], {
329
+ successLabel: 'Bir bağlantı yolda.'
330
+ });
331
+
332
+ expect(screen.getByText('Bir bağlantı yolda.')).toBeInTheDocument();
333
+ // The fence: nothing about this change may alter a published composition.
334
+ expect(container.querySelector('form')).toBeNull();
335
+ });
336
+
337
+ it('counts a HIDDEN block as none, so the shopper is still told', () => {
338
+ forgotState = { isLoading: false, isSuccess: true };
339
+
340
+ const { container } = renderForgot(
341
+ [emailField, successBlock({}, { hidden: true }), cta],
342
+ { successLabel: 'Bir bağlantı yolda.' }
343
+ );
344
+
345
+ expect(screen.getByText('Bir bağlantı yolda.')).toBeInTheDocument();
346
+ expect(container.querySelector('form')).toBeNull();
347
+ });
348
+ });
349
+
350
+ describe('the canvas', () => {
351
+ it('shows the real copy through the block on the preview toggle', () => {
352
+ const { container } = renderForgot(
353
+ [emailField, successBlock(), cta],
354
+ { successLabel: 'Bir bağlantı yolda.', previewSuccess: true },
355
+ true
356
+ );
357
+
358
+ // The state a designer is dressing, in the place they put it, with the
359
+ // form still around it — which is what they are dressing it against.
360
+ expect(
361
+ container.querySelector('[data-auth-success]')?.textContent
362
+ ).toContain('Bir bağlantı yolda.');
363
+ expect(container.querySelector('form')).not.toBeNull();
364
+ });
365
+
366
+ it('shows the block preview copy while the form is in its normal state', () => {
367
+ renderForgot(
368
+ [emailField, successBlock({ previewMessage: 'Kutunuzu kontrol edin.' }), cta],
369
+ {},
370
+ true
371
+ );
372
+
373
+ // Selectable and dressable without reaching the state for real, and
374
+ // announced to nobody: the confirmation has not happened.
375
+ const message = document.querySelector('[data-auth-success]')!;
376
+
377
+ expect(message.textContent).toContain('Kutunuzu kontrol edin.');
378
+ expect(message.getAttribute('role')).toBeNull();
379
+ expect(message.getAttribute('data-auth-success-state')).toBe('preview');
380
+ });
381
+ });
382
+
383
+ describe('the screen the mail lands on', () => {
384
+ it('keeps its composition and shows the message in the block', () => {
385
+ resetState = { isLoading: false, isSuccess: true };
386
+
387
+ const { container } = renderReset([...passwordFields, successBlock()], {
388
+ successLabel: 'Şifreniz değiştirildi.'
389
+ });
390
+
391
+ expect(
392
+ container.querySelector('[data-auth-success]')?.textContent
393
+ ).toContain('Şifreniz değiştirildi.');
394
+ expect(container.querySelector('form')).not.toBeNull();
395
+ });
396
+
397
+ it('drops the submit, because the token is spent', () => {
398
+ resetState = { isLoading: false, isSuccess: true };
399
+
400
+ const { container } = renderReset([...passwordFields, successBlock()]);
401
+
402
+ expect(container.querySelector('[data-reset-password-submit]')).toBeNull();
403
+ });
404
+
405
+ it('refuses a second submit, which is all a detached CTA could reach', () => {
406
+ resetState = { isLoading: false, isSuccess: true };
407
+
408
+ const { container } = renderReset([...passwordFields, successBlock()], {
409
+ formHtmlId: 'reset-form'
410
+ });
411
+
412
+ fireEvent.submit(container.querySelector('form')!);
413
+
414
+ // A Button carrying the form's id sits outside this subtree, so dropping
415
+ // the built-in button cannot stop it — the handler has to.
416
+ expect(passwordReset).not.toHaveBeenCalled();
417
+ });
418
+
419
+ it('is still replaced outright where no block was placed', () => {
420
+ resetState = { isLoading: false, isSuccess: true };
421
+
422
+ const { container } = renderReset(passwordFields, {
423
+ successLabel: 'Şifreniz değiştirildi.'
424
+ });
425
+
426
+ expect(screen.getByText('Şifreniz değiştirildi.')).toBeInTheDocument();
427
+ expect(container.querySelector('form')).toBeNull();
428
+ });
429
+ });
@@ -22,6 +22,8 @@ jest.mock('next-auth/react', () => ({
22
22
  }));
23
23
 
24
24
  jest.mock('next/navigation', () => ({
25
+ usePathname: () => '/',
26
+ useSearchParams: () => new URLSearchParams(),
25
27
  useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
26
28
  }));
27
29
 
@@ -23,7 +23,12 @@ const APP_DIR = path.resolve(
23
23
  /** Placeholder key -> the file (relative to `[pz]`) mounting it. */
24
24
  const EXPECTED_MOUNTS: Record<string, string> = {
25
25
  auth: 'auth/(login)/layout.tsx',
26
- forgotPassword: 'users/password/reset/layout.tsx'
26
+ forgotPassword: 'users/password/reset/layout.tsx',
27
+ // The screen the reset MAIL lands on, which is a different page from the one
28
+ // that asks for the mail. It was the last auth slug the editor offered and
29
+ // nothing mounted: a designer could compose the screen, publish it, and the
30
+ // shopper coming out of their inbox still met the hand-written page.
31
+ resetPassword: 'users/reset/[[...id]]/layout.tsx'
27
32
  };
28
33
 
29
34
  /**
@@ -52,6 +57,9 @@ const AUTH_PICKER_PATHS = [
52
57
  '/register',
53
58
  '/auth/forgot-password',
54
59
  '/users/password/reset',
60
+ // The mail's own link, token and all — a catch-all route, so the path has
61
+ // segments the rule has to see past.
62
+ '/users/reset/tok/abc123',
55
63
  '/auth/guest',
56
64
  '/users/auth/guest'
57
65
  ];
@@ -18,6 +18,8 @@ import { Block } from '../theme-block';
18
18
  */
19
19
 
20
20
  jest.mock('next/navigation', () => ({
21
+ usePathname: () => '/',
22
+ useSearchParams: () => new URLSearchParams(),
21
23
  useRouter: () => ({ push: jest.fn(), refresh: jest.fn() })
22
24
  }));
23
25
 
@@ -0,0 +1,154 @@
1
+ import {
2
+ BLOCK_ACTION_AUDIENCES,
3
+ applyVisibilityRulesToSections
4
+ } from '../utils/visibility-rules';
5
+ import { Block } from '../theme-block';
6
+ import { Section } from '../theme-section';
7
+
8
+ /**
9
+ * Who a block's ACTION is for.
10
+ *
11
+ * The bug this exists for: the account button renders its own link — to the
12
+ * account for a member, to the login page for a guest — while the designer
13
+ * wires "open the login drawer" onto the block itself. Both fired on one
14
+ * click, so a signed-in shopper landed on their own account page with the
15
+ * login drawer open over it.
16
+ *
17
+ * Resolved in the pass that already knows who the shopper is, so an action the
18
+ * shopper is not the audience for is REMOVED rather than ignored at click
19
+ * time — the wrapper then never claims to be a dialog trigger either.
20
+ */
21
+
22
+ const context = (authState: 'authenticated' | 'guest' | 'loading') => ({
23
+ authState,
24
+ breakpoint: 'desktop',
25
+ locale: 'tr',
26
+ currency: 'TRY',
27
+ pathname: '/account',
28
+ searchParams: null
29
+ });
30
+
31
+ const sectionWith = (block: Block): Section =>
32
+ ({
33
+ id: 'section-1',
34
+ type: 'custom',
35
+ label: 'Header',
36
+ order: 0,
37
+ hidden: false,
38
+ properties: {},
39
+ styles: {},
40
+ blocks: [block]
41
+ }) as unknown as Section;
42
+
43
+ const blockOf = (
44
+ type: string,
45
+ properties: Record<string, unknown>
46
+ ): Block =>
47
+ ({
48
+ id: 'block-1',
49
+ type,
50
+ label: type,
51
+ value: '',
52
+ order: 0,
53
+ hidden: false,
54
+ properties,
55
+ styles: {}
56
+ }) as unknown as Block;
57
+
58
+ const actionTypeAfter = (
59
+ block: Block,
60
+ authState: 'authenticated' | 'guest' | 'loading'
61
+ ): unknown =>
62
+ applyVisibilityRulesToSections([sectionWith(block)], context(authState))[0]
63
+ .blocks[0].properties?.actionType;
64
+
65
+ describe('an account button that opens a modal', () => {
66
+ const accountButton = blockOf('account-button', {
67
+ actionType: 'open-modal',
68
+ actionTarget: 'm-login'
69
+ });
70
+
71
+ it('keeps the drawer for a guest', () => {
72
+ expect(actionTypeAfter(accountButton, 'guest')).toBe('open-modal');
73
+ });
74
+
75
+ it('drops it for a member, who is already being taken to their account', () => {
76
+ expect(actionTypeAfter(accountButton, 'authenticated')).toBe('none');
77
+ });
78
+
79
+ it('keeps it while the session is unknown', () => {
80
+ // Withholding the drawer through the first client render would break the
81
+ // common case to fix the rare one.
82
+ expect(actionTypeAfter(accountButton, 'loading')).toBe('open-modal');
83
+ });
84
+
85
+ it('obeys a brand that really wants it for everyone', () => {
86
+ const authored = blockOf('account-button', {
87
+ actionType: 'open-modal',
88
+ actionTarget: 'm-login',
89
+ actionAudience: 'all'
90
+ });
91
+
92
+ expect(actionTypeAfter(authored, 'authenticated')).toBe('open-modal');
93
+ });
94
+
95
+ it('leaves its navigation alone — only the wired action is scoped', () => {
96
+ const navigating = blockOf('account-button', { actionType: 'none' });
97
+
98
+ expect(actionTypeAfter(navigating, 'authenticated')).toBe('none');
99
+ });
100
+ });
101
+
102
+ describe('any other block', () => {
103
+ it('is unscoped unless the designer says otherwise', () => {
104
+ const button = blockOf('button', {
105
+ actionType: 'open-modal',
106
+ actionTarget: 'm-newsletter'
107
+ });
108
+
109
+ expect(actionTypeAfter(button, 'authenticated')).toBe('open-modal');
110
+ expect(actionTypeAfter(button, 'guest')).toBe('open-modal');
111
+ });
112
+
113
+ it('can be scoped to guests — a "sign in" CTA in a campaign band', () => {
114
+ const cta = blockOf('button', {
115
+ actionType: 'open-modal',
116
+ actionTarget: 'm-login',
117
+ actionAudience: 'guest'
118
+ });
119
+
120
+ expect(actionTypeAfter(cta, 'guest')).toBe('open-modal');
121
+ expect(actionTypeAfter(cta, 'authenticated')).toBe('none');
122
+ });
123
+
124
+ it('can be scoped to members — a "my coupons" drawer', () => {
125
+ const memberOnly = blockOf('button', {
126
+ actionType: 'open-modal',
127
+ actionTarget: 'm-coupons',
128
+ actionAudience: 'authenticated'
129
+ });
130
+
131
+ expect(actionTypeAfter(memberOnly, 'authenticated')).toBe('open-modal');
132
+ expect(actionTypeAfter(memberOnly, 'guest')).toBe('none');
133
+ // Unknown is not a member, so the action stays off until it is known.
134
+ expect(actionTypeAfter(memberOnly, 'loading')).toBe('none');
135
+ });
136
+
137
+ it('reads the answer for the breakpoint being rendered', () => {
138
+ const perBreakpoint = blockOf('button', {
139
+ actionType: { desktop: 'open-modal' },
140
+ actionTarget: { desktop: 'm-login' },
141
+ actionAudience: { desktop: 'authenticated' }
142
+ });
143
+
144
+ expect(actionTypeAfter(perBreakpoint, 'guest')).toBe('none');
145
+ });
146
+
147
+ it('offers exactly three audiences', () => {
148
+ expect([...BLOCK_ACTION_AUDIENCES]).toEqual([
149
+ 'all',
150
+ 'guest',
151
+ 'authenticated'
152
+ ]);
153
+ });
154
+ });