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

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 (62) hide show
  1. package/CHANGELOG.md +8 -33
  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/__tests__/slider-block.test.tsx +265 -1
  30. package/src/blocks/account-button-block.tsx +25 -0
  31. package/src/blocks/add-to-cart-block.tsx +15 -0
  32. package/src/blocks/auth-field-block.tsx +13 -3
  33. package/src/blocks/auth-flow-block.tsx +45 -3
  34. package/src/blocks/auth-form-block.tsx +337 -54
  35. package/src/blocks/auth-success-block.tsx +85 -0
  36. package/src/blocks/block-renderer-registry.tsx +8 -0
  37. package/src/blocks/button-block.tsx +43 -13
  38. package/src/blocks/captcha-block.tsx +61 -0
  39. package/src/blocks/change-password-form-block.tsx +15 -0
  40. package/src/blocks/forgot-password-block.tsx +132 -9
  41. package/src/blocks/form-state-block.tsx +84 -0
  42. package/src/blocks/newsletter-form-block.tsx +16 -0
  43. package/src/blocks/otp-form-block.tsx +161 -67
  44. package/src/blocks/profile-form-block.tsx +13 -0
  45. package/src/blocks/register-form-block.tsx +362 -110
  46. package/src/blocks/reset-password-block.tsx +717 -0
  47. package/src/blocks/slider-block.tsx +170 -4
  48. package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
  49. package/src/chrome/placeholder-slugs.ts +3 -0
  50. package/src/chrome/route-slug.ts +11 -0
  51. package/src/index.ts +10 -0
  52. package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
  53. package/src/overlays/__tests__/modal-registry.test.ts +5 -1
  54. package/src/overlays/modal-chrome-css.ts +8 -1
  55. package/src/overlays/modal-host-context.tsx +66 -0
  56. package/src/overlays/modal-preview-host.tsx +7 -2
  57. package/src/overlays/modal-registry.ts +38 -1
  58. package/src/overlays/overlay-loading.tsx +144 -0
  59. package/src/overlays/overlay-surface.tsx +23 -0
  60. package/src/overlays/overlay-tokens.ts +12 -0
  61. package/src/overlays/theme-modal-mount.tsx +85 -6
  62. package/src/utils/visibility-rules.ts +114 -0
@@ -39,9 +39,17 @@ const EXPECTED_BLOCK_TYPES = [
39
39
  'auth-form',
40
40
  'auth-step',
41
41
  'auth-step-title',
42
+ // The confirmation as a placeable block, the success twin of 'auth-error':
43
+ // the two forms that have a success state publish their message and this
44
+ // draws it where the design put it. See `auth/form-success-context.tsx`.
45
+ 'auth-success',
42
46
  'basket-items',
43
47
  'basket-summary',
44
48
  'button',
49
+ // The security check commerce escalates to, placed inside an auth form.
50
+ 'captcha',
51
+ // The screen the password-reset mail lands on.
52
+ 'reset-password',
45
53
  'change-password-form',
46
54
  'checkout-flow-outlet',
47
55
  'checkout-steps',
@@ -60,6 +68,10 @@ const EXPECTED_BLOCK_TYPES = [
60
68
  'favorite-button',
61
69
  'favourites-grid',
62
70
  'forgot-password',
71
+ // One screen of a composed form — the confirmation, the refusal, the busy
72
+ // moment — as a container the form draws when it is on that screen. See
73
+ // `auth/form-state-context.tsx`.
74
+ 'form-state',
63
75
  'group',
64
76
  'hamburger-menu',
65
77
  'hotspot',
@@ -0,0 +1,385 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import ResetPasswordBlock from '../blocks/reset-password-block';
5
+ import { __resetAuthFlowBusForTests, getAuthFlowState } from '../auth/auth-flow-bus';
6
+ import { Block } from '../theme-block';
7
+
8
+ /**
9
+ * The screen the password-reset MAIL lands on.
10
+ *
11
+ * `forgot-password` composes the request and then handed the shopper to a page
12
+ * every brand hand-wrote — the same two inputs and the same four states,
13
+ * drifting from the drawer the journey started in, and invisible to the editor.
14
+ * These cases pin the four states, the one rule the form checks itself (the two
15
+ * passwords matching), and the attribution of a commerce refusal that arrives
16
+ * under the names commerce uses rather than the ones on screen.
17
+ */
18
+
19
+ const passwordReset = jest.fn();
20
+ const unwrap = jest.fn();
21
+ let resetState = { isLoading: false, isSuccess: false };
22
+ let validation: {
23
+ data?: { validlink: boolean };
24
+ isLoading: boolean;
25
+ isError: boolean;
26
+ } = { data: { validlink: true }, isLoading: false, isError: false };
27
+
28
+ jest.mock('@akinon/next/data/client/account', () => ({
29
+ usePasswordResetMutation: () => [
30
+ (...args: unknown[]) => {
31
+ passwordReset(...args);
32
+
33
+ return { unwrap };
34
+ },
35
+ resetState
36
+ ],
37
+ useGetValidatePasswordResetTokenQuery: () => validation
38
+ }));
39
+
40
+ let params: Record<string, string | string[]> = { id: ['tok', 'abc123'] };
41
+ let search = new URLSearchParams();
42
+
43
+ jest.mock('next/navigation', () => ({
44
+ useParams: () => params,
45
+ useSearchParams: () => search,
46
+ usePathname: () => '/users/reset/tok/abc123'
47
+ }));
48
+
49
+ const block = (overrides: Partial<Block>): Block =>
50
+ ({
51
+ id: 'blk',
52
+ type: 'text',
53
+ label: 'Block',
54
+ value: '',
55
+ order: 0,
56
+ hidden: false,
57
+ properties: {},
58
+ styles: {},
59
+ ...overrides
60
+ }) as Block;
61
+
62
+ const authField = (
63
+ order: number,
64
+ properties: Record<string, unknown>
65
+ ): Block =>
66
+ block({
67
+ id: `field-${order}`,
68
+ type: 'auth-field',
69
+ label: 'Field',
70
+ properties,
71
+ order
72
+ });
73
+
74
+ /** The two boxes as a designer composes them: a preset one and a custom one. */
75
+ const COMPOSED = [
76
+ authField(0, { fieldType: 'password', label: 'New password' }),
77
+ authField(1, {
78
+ fieldType: 'custom',
79
+ name: 'repeatPassword',
80
+ inputType: 'password',
81
+ label: 'New password again',
82
+ required: true
83
+ })
84
+ ];
85
+
86
+ const renderBlock = (properties = {}, children?: Block[]) =>
87
+ render(
88
+ <ResetPasswordBlock
89
+ block={
90
+ block({
91
+ id: 'reset-1',
92
+ type: 'reset-password',
93
+ label: 'Reset Password',
94
+ properties,
95
+ blocks: children
96
+ }) as Block
97
+ }
98
+ placeholderId="placeholder-1"
99
+ sectionId="section-1"
100
+ currentBreakpoint="desktop"
101
+ />
102
+ );
103
+
104
+ const fill = (container: HTMLElement, first: string, second: string) => {
105
+ fireEvent.change(container.querySelector('input[name="password"]')!, {
106
+ target: { value: first }
107
+ });
108
+ fireEvent.change(container.querySelector('input[name="repeatPassword"]')!, {
109
+ target: { value: second }
110
+ });
111
+ };
112
+
113
+ beforeEach(() => {
114
+ passwordReset.mockClear();
115
+ unwrap.mockReset();
116
+ unwrap.mockResolvedValue(undefined);
117
+ resetState = { isLoading: false, isSuccess: false };
118
+ validation = { data: { validlink: true }, isLoading: false, isError: false };
119
+ params = { id: ['tok', 'abc123'] };
120
+ search = new URLSearchParams();
121
+ __resetAuthFlowBusForTests();
122
+ });
123
+
124
+ describe('the link the mail carries', () => {
125
+ it('is read back out of the catch-all route segment, slashes and all', async () => {
126
+ const { container } = renderBlock();
127
+
128
+ fill(container, 'hunter2A!', 'hunter2A!');
129
+ fireEvent.submit(container.querySelector('form')!);
130
+
131
+ await waitFor(() => expect(passwordReset).toHaveBeenCalled());
132
+ // The token contains slashes, so the segments have to be put back together
133
+ // — a single segment would be a different token.
134
+ expect(passwordReset).toHaveBeenCalledWith({
135
+ password: 'hunter2A!',
136
+ repeatPassword: 'hunter2A!',
137
+ slug: 'tok/abc123'
138
+ });
139
+ });
140
+
141
+ it('can be read from the query instead, for a brand whose route differs', async () => {
142
+ params = {};
143
+ search = new URLSearchParams('code=xyz789');
144
+
145
+ const { container } = renderBlock({
146
+ tokenSource: 'query',
147
+ tokenQueryKey: 'code'
148
+ });
149
+
150
+ fill(container, 'hunter2A!', 'hunter2A!');
151
+ fireEvent.submit(container.querySelector('form')!);
152
+
153
+ await waitFor(() => expect(passwordReset).toHaveBeenCalled());
154
+ expect(passwordReset.mock.calls[0][0].slug).toBe('xyz789');
155
+ });
156
+
157
+ it('answers a spent link with the expired state, not with a form', () => {
158
+ validation = { data: { validlink: false }, isLoading: false, isError: false };
159
+
160
+ renderBlock({ expiredLabel: 'Bu bağlantının süresi doldu.' });
161
+
162
+ expect(screen.getByText('Bu bağlantının süresi doldu.')).toBeInTheDocument();
163
+ expect(document.querySelector('form')).toBeNull();
164
+ });
165
+
166
+ it('treats no link at all as the same dead end', () => {
167
+ params = {};
168
+
169
+ renderBlock({ expiredLabel: 'Bu bağlantının süresi doldu.' });
170
+
171
+ expect(screen.getByText('Bu bağlantının süresi doldu.')).toBeInTheDocument();
172
+ });
173
+
174
+ it('sends an expired link back to the step that can issue a new one', () => {
175
+ validation = { data: { validlink: false }, isLoading: false, isError: false };
176
+
177
+ renderBlock({
178
+ expiredStepTarget: 'auth:forgot',
179
+ backLabel: 'Yeni bağlantı iste'
180
+ });
181
+
182
+ fireEvent.click(screen.getByText('Yeni bağlantı iste'));
183
+
184
+ // A fresh mail is the only way forward, and the flow is what knows where
185
+ // that lives.
186
+ expect(getAuthFlowState('auth')?.active).toBe('forgot');
187
+ });
188
+
189
+ it('says nothing while the link is being checked, unless asked to', () => {
190
+ validation = { isLoading: true, isError: false };
191
+
192
+ const { container, unmount } = renderBlock();
193
+ // A sentence that appears and vanishes within a fraction of a second is
194
+ // worse than nothing.
195
+ expect(container.innerHTML).toBe('');
196
+
197
+ unmount();
198
+ renderBlock({ checkingLabel: 'Bağlantınız kontrol ediliyor…' });
199
+
200
+ expect(
201
+ screen.getByText('Bağlantınız kontrol ediliyor…')
202
+ ).toBeInTheDocument();
203
+ });
204
+ });
205
+
206
+ describe('the two boxes', () => {
207
+ it('refuses a mismatch before the request leaves', async () => {
208
+ const { container } = renderBlock({
209
+ mismatchMessage: 'İki şifre aynı değil.'
210
+ });
211
+
212
+ fill(container, 'hunter2A!', 'hunter3B!');
213
+ fireEvent.submit(container.querySelector('form')!);
214
+
215
+ expect(screen.getByText('İki şifre aynı değil.')).toBeInTheDocument();
216
+ expect(passwordReset).not.toHaveBeenCalled();
217
+ // Under the second box, which is the one that has to change.
218
+ expect(
219
+ container.querySelector('input[name="repeatPassword"]')
220
+ ).toHaveAttribute('aria-invalid', 'true');
221
+ });
222
+
223
+ it('tells a password manager this is a NEW password', () => {
224
+ const { container } = renderBlock();
225
+
226
+ // Both boxes: a manager offers to generate and save only for
227
+ // `new-password`, and this screen is exactly where it should.
228
+ expect(
229
+ container.querySelector('input[name="password"]')
230
+ ).toHaveAttribute('autocomplete', 'new-password');
231
+ expect(
232
+ container.querySelector('input[name="repeatPassword"]')
233
+ ).toHaveAttribute('autocomplete', 'new-password');
234
+ });
235
+
236
+ it('draws the children instead of its own rows once composed', () => {
237
+ const { container } = renderBlock({}, COMPOSED);
238
+
239
+ expect(
240
+ container.querySelector('[data-reset-password-composed]')
241
+ ).not.toBeNull();
242
+ expect(container.querySelectorAll('[data-reset-password-field]')).toHaveLength(
243
+ 0
244
+ );
245
+ // One of each, not two: the fixed rows are what a composition replaces.
246
+ expect(container.querySelectorAll('input[name="password"]')).toHaveLength(1);
247
+ expect(
248
+ container.querySelectorAll('input[name="repeatPassword"]')
249
+ ).toHaveLength(1);
250
+ });
251
+
252
+ it('submits what a composed form collected', async () => {
253
+ const { container } = renderBlock({}, COMPOSED);
254
+
255
+ fill(container, 'hunter2A!', 'hunter2A!');
256
+ fireEvent.submit(container.querySelector('form')!);
257
+
258
+ await waitFor(() => expect(passwordReset).toHaveBeenCalled());
259
+ expect(passwordReset.mock.calls[0][0]).toMatchObject({
260
+ password: 'hunter2A!',
261
+ repeatPassword: 'hunter2A!'
262
+ });
263
+ });
264
+ });
265
+
266
+ describe('what commerce refuses', () => {
267
+ it('lands under the box it is about, despite the name it arrives under', async () => {
268
+ // The mutation posts `new_password1` / `new_password2`, so that is what a
269
+ // refusal names — and nothing on screen submits under those.
270
+ unwrap.mockRejectedValue({
271
+ data: { new_password1: ['This password is too common.'] }
272
+ });
273
+
274
+ const { container } = renderBlock();
275
+
276
+ fill(container, 'password', 'password');
277
+ fireEvent.submit(container.querySelector('form')!);
278
+
279
+ await waitFor(() =>
280
+ expect(
281
+ screen.getByText('This password is too common.')
282
+ ).toBeInTheDocument()
283
+ );
284
+
285
+ expect(container.querySelector('input[name="password"]')).toHaveAttribute(
286
+ 'aria-invalid',
287
+ 'true'
288
+ );
289
+ });
290
+
291
+ it('keeps a form-level refusal above the form', async () => {
292
+ unwrap.mockRejectedValue({
293
+ data: { non_field_errors: ['This link has already been used.'] }
294
+ });
295
+
296
+ const { container } = renderBlock();
297
+
298
+ fill(container, 'hunter2A!', 'hunter2A!');
299
+ fireEvent.submit(container.querySelector('form')!);
300
+
301
+ await waitFor(() =>
302
+ expect(
303
+ screen.getByText('This link has already been used.')
304
+ ).toBeInTheDocument()
305
+ );
306
+ });
307
+
308
+ it('falls back to the authored sentence when nothing legible came back', async () => {
309
+ unwrap.mockRejectedValue({ status: 500 });
310
+
311
+ const { container } = renderBlock({ errorLabel: 'Bir şeyler ters gitti.' });
312
+
313
+ fill(container, 'hunter2A!', 'hunter2A!');
314
+ fireEvent.submit(container.querySelector('form')!);
315
+
316
+ await waitFor(() =>
317
+ expect(screen.getByText('Bir şeyler ters gitti.')).toBeInTheDocument()
318
+ );
319
+ });
320
+ });
321
+
322
+ describe('once the password is changed', () => {
323
+ it('replaces the form, because the token is spent', () => {
324
+ resetState = { isLoading: false, isSuccess: true };
325
+
326
+ renderBlock({ successLabel: 'Şifreniz değiştirildi.' });
327
+
328
+ expect(screen.getByText('Şifreniz değiştirildi.')).toBeInTheDocument();
329
+ expect(document.querySelector('form')).toBeNull();
330
+ });
331
+
332
+ it('hands the journey to a step when the designer named one', async () => {
333
+ const { container } = renderBlock({ successStepTarget: 'auth:done' });
334
+
335
+ fill(container, 'hunter2A!', 'hunter2A!');
336
+ fireEvent.submit(container.querySelector('form')!);
337
+
338
+ await waitFor(() => expect(getAuthFlowState('auth')?.active).toBe('done'));
339
+ });
340
+ });
341
+
342
+ describe('in the canvas', () => {
343
+ const renderDesigner = (properties = {}) =>
344
+ render(
345
+ <ResetPasswordBlock
346
+ block={
347
+ block({
348
+ id: 'reset-1',
349
+ type: 'reset-password',
350
+ label: 'Reset Password',
351
+ properties
352
+ }) as Block
353
+ }
354
+ placeholderId="placeholder-1"
355
+ sectionId="section-1"
356
+ currentBreakpoint="desktop"
357
+ isDesigner
358
+ />
359
+ );
360
+
361
+ it('shows the state the designer asked to style', () => {
362
+ renderDesigner({ previewState: 'expired', expiredLabel: 'Süresi doldu.' });
363
+
364
+ expect(screen.getByText('Süresi doldu.')).toBeInTheDocument();
365
+ });
366
+
367
+ it('shows the form by default, whatever a real token would have said', () => {
368
+ validation = { data: { validlink: false }, isLoading: false, isError: false };
369
+
370
+ const { container } = renderDesigner();
371
+
372
+ // The designer has no token, so asking commerce about one would answer
373
+ // "expired" for every composition.
374
+ expect(container.querySelector('form')).not.toBeNull();
375
+ });
376
+
377
+ it('never spends a token', () => {
378
+ const { container } = renderDesigner();
379
+
380
+ fill(container, 'hunter2A!', 'hunter2A!');
381
+ fireEvent.submit(container.querySelector('form')!);
382
+
383
+ expect(passwordReset).not.toHaveBeenCalled();
384
+ });
385
+ });
@@ -0,0 +1,125 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { useCaptcha } from '@akinon/next/hooks';
5
+
6
+ /**
7
+ * The security check commerce escalates to after too many attempts.
8
+ *
9
+ * Nothing in the composable auth set could answer it before: the provider
10
+ * reports the escalation as `{ type: 'captcha' }`, the forms sent no
11
+ * `captchaValidated`, and the challenge reached the shopper as the literal
12
+ * `[{"type":"captcha"}]` in the error slot. A brand whose commerce is
13
+ * abuse-protected therefore could not use composed login or sign-up at all —
14
+ * every submit after the threshold was a dead end.
15
+ *
16
+ * What this adds is the SEAM, not a second captcha implementation: the widget,
17
+ * the site key and the validation round trip all stay in `useCaptcha` from
18
+ * `@akinon/next`, which is the same hook the brands' own hand-written views
19
+ * use. What lives here is where the widget is DRAWN — a composition places a
20
+ * Captcha block, a childless form draws it above its submit — and how a form
21
+ * reaches the two things it needs at submit time.
22
+ *
23
+ * Mounted only when the form's `showCaptcha` is on, and that is deliberate:
24
+ * `useCaptcha` fetches the site key on mount, and a storefront whose commerce
25
+ * never escalates should not pay a request per auth form for a challenge it
26
+ * will never be shown.
27
+ */
28
+
29
+ export interface AuthCaptchaApi {
30
+ /** The widget itself; renders nothing until the challenge is revealed. */
31
+ View: React.ComponentType<{ className?: string }>;
32
+ /** Whether the challenge is on screen. */
33
+ isVisible: boolean;
34
+ /** Whether the shopper has solved it. */
35
+ validated: boolean;
36
+ /**
37
+ * Reveal the challenge, or validate a solved one. Resolves true only when
38
+ * commerce accepted the answer — which is the moment a submit may be retried.
39
+ */
40
+ validate: () => Promise<boolean>;
41
+ }
42
+
43
+ const AuthCaptchaContext = React.createContext<AuthCaptchaApi | null>(null);
44
+
45
+ /** The api for a Captcha block, or null when no form is offering one. */
46
+ export const useAuthCaptcha = (): AuthCaptchaApi | null =>
47
+ React.useContext(AuthCaptchaContext);
48
+
49
+ interface AuthCaptchaHostProps {
50
+ /**
51
+ * Called whenever the api changes, so the form can read `validated` and
52
+ * `validate` at submit time without re-rendering itself on every captcha
53
+ * state change.
54
+ */
55
+ onApi: (api: AuthCaptchaApi) => void;
56
+ children: React.ReactNode;
57
+ }
58
+
59
+ /**
60
+ * Wraps a form's contents while `showCaptcha` is on: hands the api down to any
61
+ * Captcha block inside, and up to the form through `onApi`.
62
+ */
63
+ export const AuthCaptchaHost = ({ onApi, children }: AuthCaptchaHostProps) => {
64
+ const { CaptchaView, validated, isVisible, validate } = useCaptcha();
65
+
66
+ const api = React.useMemo<AuthCaptchaApi>(
67
+ () => ({ View: CaptchaView, validated, isVisible, validate }),
68
+ // `validate` is re-created every render by the hook, so it is deliberately
69
+ // NOT a dependency: including it would rebuild the api — and re-run the
70
+ // effect below — on every keystroke in the form.
71
+ // eslint-disable-next-line react-hooks/exhaustive-deps
72
+ [CaptchaView, validated, isVisible]
73
+ );
74
+
75
+ const apiRef = React.useRef(api);
76
+ apiRef.current = { ...api, validate };
77
+
78
+ React.useEffect(() => {
79
+ onApi(apiRef.current);
80
+ }, [api, onApi]);
81
+
82
+ return (
83
+ <AuthCaptchaContext.Provider value={api}>
84
+ {children}
85
+ </AuthCaptchaContext.Provider>
86
+ );
87
+ };
88
+
89
+ /**
90
+ * The widget, drawn by the FORM rather than by a block.
91
+ *
92
+ * The fallback for a composition that has not placed a Captcha block, and the
93
+ * only way a childless form can show one at all — the same relationship the
94
+ * built-in error line has with a Form Error block. Renders nothing until
95
+ * commerce escalates.
96
+ */
97
+ export const AuthCaptchaSlot = () => {
98
+ const captcha = useAuthCaptcha();
99
+
100
+ if (!captcha) return null;
101
+
102
+ return (
103
+ <div data-auth-captcha="">
104
+ <captcha.View />
105
+ </div>
106
+ );
107
+ };
108
+
109
+ /**
110
+ * Whether the composition placed a Captcha block itself.
111
+ *
112
+ * Same question, and the same shape of answer, as `containsAuthErrorBlock`: a
113
+ * form that finds one draws nothing of its own, so the challenge appears where
114
+ * the designer put it rather than twice.
115
+ */
116
+ export const containsCaptchaBlock = (
117
+ blocks: { type?: string; blocks?: unknown[] }[] | undefined
118
+ ): boolean =>
119
+ (blocks || []).some(
120
+ (block) =>
121
+ block?.type === 'captcha' ||
122
+ containsCaptchaBlock(
123
+ block?.blocks as { type?: string; blocks?: unknown[] }[] | undefined
124
+ )
125
+ );