@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
@@ -87,8 +87,29 @@ const ButtonBlock = ({
87
87
  return 'Click Me';
88
88
  };
89
89
 
90
+ const submitFormId = String(
91
+ getResponsiveValue(block.properties?.submitFormId, currentBreakpoint, '')
92
+ ).trim();
93
+ /**
94
+ * A detached CTA is a real submit button and has to behave like one while the
95
+ * form it drives is working: staying bright and clickable through a request
96
+ * is what makes a shopper press it twice. An empty id costs no subscription,
97
+ * so this is safe to read unconditionally — as a hook must be.
98
+ */
99
+ const isSubmittedFormBusy = useFormSubmitState(submitFormId);
100
+
90
101
  const textValue = block.properties?.text || block.value;
91
- const text = getLocalizedText(textValue);
102
+ /**
103
+ * What the caption says while the form this button drives is working.
104
+ *
105
+ * Empty keeps the caption put, which is what every published button does —
106
+ * the same absence-preserving default the forms' own `busyLabel` carries.
107
+ */
108
+ const busyTextValue = block.properties?.busyText;
109
+ const text =
110
+ isSubmittedFormBusy && busyTextValue
111
+ ? getLocalizedText(busyTextValue)
112
+ : getLocalizedText(textValue);
92
113
 
93
114
  const url = block.properties?.url
94
115
  ? String(getResponsiveValue(block.properties.url, currentBreakpoint, '#'))
@@ -133,16 +154,6 @@ const ButtonBlock = ({
133
154
  * live in a pinned bottom bar while the fields stay in the flow above.
134
155
  * Forces the <button> branch: an anchor cannot submit anything.
135
156
  */
136
- const submitFormId = String(
137
- getResponsiveValue(block.properties?.submitFormId, currentBreakpoint, '')
138
- ).trim();
139
- /**
140
- * A detached CTA is a real submit button and has to behave like one while the
141
- * form it drives is working: staying bright and clickable through a request
142
- * is what makes a shopper press it twice. An empty id costs no subscription,
143
- * so this is safe to read unconditionally — as a hook must be.
144
- */
145
- const isSubmittedFormBusy = useFormSubmitState(submitFormId);
146
157
 
147
158
  const iconUrl = block.properties?.icon
148
159
  ? String(getResponsiveValue(block.properties.icon, currentBreakpoint, ''))
@@ -660,8 +671,27 @@ const ButtonBlock = ({
660
671
  // root styles HERE instead of onto the designer wrapper, so padding
661
672
  // and border land once, on the element the shopper actually clicks —
662
673
  // not once on the wrapper and again inline (which doubled them).
663
- {...partAttrs('root')}
664
- style={finalStyles}
674
+ //
675
+ // The states travel with it: `disabled` alone changed nothing a
676
+ // shopper could see, because an authored background and colour
677
+ // override what a browser paints on a disabled button — so a
678
+ // detached CTA stayed bright and clickable-looking through the whole
679
+ // request, which is exactly what makes someone press it twice.
680
+ {...partAttrs('root', {
681
+ loading: isSubmittedFormBusy,
682
+ disabled: isSubmittedFormBusy
683
+ })}
684
+ style={
685
+ isSubmittedFormBusy
686
+ ? {
687
+ ...finalStyles,
688
+ // A default a designer does not have to author, and the same
689
+ // one the forms' own submit buttons use.
690
+ opacity: 0.7,
691
+ cursor: 'not-allowed'
692
+ }
693
+ : finalStyles
694
+ }
665
695
  onClick={handleClick}
666
696
  {...action.ariaProps}
667
697
  >
@@ -0,0 +1,61 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { getResponsiveValue, partAttrs } from '../utils';
5
+ import { useAuthCaptcha } from '../auth/auth-captcha';
6
+ import { DesignerPlaceholder } from './product-block-utils';
7
+ import { BlockRendererProps } from './block-renderer-registry';
8
+
9
+ /**
10
+ * Where the security check appears inside an auth form.
11
+ *
12
+ * The challenge itself is not a design decision — commerce decides when to
13
+ * escalate, and the widget is Google's — but WHERE it lands is: above the
14
+ * submit, under the password, inside the drawer's scrolling area rather than
15
+ * behind its bottom bar. So it is a block, and the form it sits in offers the
16
+ * widget through `auth/auth-captcha.tsx`.
17
+ *
18
+ * Renders nothing at all until there is something to render: the form has
19
+ * `showCaptcha` off, or commerce has not escalated. That is what lets a
20
+ * designer place it once and forget it, instead of composing a second version
21
+ * of the form for the abuse-protected case.
22
+ *
23
+ * Properties: label. Parts: captcha.
24
+ */
25
+ const CaptchaBlock = ({
26
+ block,
27
+ currentBreakpoint = 'desktop',
28
+ isDesigner = false
29
+ }: BlockRendererProps) => {
30
+ const captcha = useAuthCaptcha();
31
+
32
+ const label = String(
33
+ getResponsiveValue(
34
+ block.properties?.label,
35
+ currentBreakpoint,
36
+ 'Security check'
37
+ )
38
+ );
39
+
40
+ /**
41
+ * In the canvas it is a marker rather than a live widget. Two reasons, and
42
+ * the first is enough on its own: rendering reCAPTCHA into an editor iframe
43
+ * asks Google for a token against the wrong origin. The second is that a
44
+ * designer has to be able to SEE and select a block whose whole point is to
45
+ * be invisible most of the time.
46
+ */
47
+ if (isDesigner) {
48
+ return <DesignerPlaceholder label={label} attribute="data-auth-captcha" />;
49
+ }
50
+
51
+ // No form is offering a challenge — see the note above.
52
+ if (!captcha) return null;
53
+
54
+ return (
55
+ <div {...partAttrs('captcha')} data-auth-captcha="">
56
+ <captcha.View />
57
+ </div>
58
+ );
59
+ };
60
+
61
+ export default CaptchaBlock;
@@ -3,6 +3,7 @@
3
3
  import React, { useState } from 'react';
4
4
  import { useUpdatePasswordMutation } from '@akinon/next/data/client/account';
5
5
  import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { useHostModalCloser } from '../overlays/modal-host-context';
6
7
  import { BlockRendererProps } from './block-renderer-registry';
7
8
  import { readToggle } from './product-block-utils';
8
9
 
@@ -63,6 +64,19 @@ const ChangePasswordFormBlock = ({
63
64
  getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
64
65
  );
65
66
 
67
+ /**
68
+ * Whether finishing closes the modal this block is inside. OFF by default
69
+ * here, because this block's success state is something the shopper is meant
70
+ * to read — see `useHostModalCloser`.
71
+ */
72
+ const closeHostModalOnSuccess = readToggle(
73
+ block.properties?.closeHostModalOnSuccess,
74
+ currentBreakpoint,
75
+ false
76
+ );
77
+ const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
78
+
79
+
66
80
  const submitLabel = text('submitLabel', 'Change password');
67
81
  const submitAlign = text('submitAlign', 'stretch');
68
82
  const currentPasswordLabel = text('currentPasswordLabel', 'Current password');
@@ -125,6 +139,7 @@ const ChangePasswordFormBlock = ({
125
139
  await updatePassword(values).unwrap();
126
140
  setValues(EMPTY_FORM);
127
141
  setIsDone(true);
142
+ closeHostModal();
128
143
  } catch (requestError) {
129
144
  setError(readPasswordError(requestError, errorMessage));
130
145
  }
@@ -8,12 +8,28 @@ import {
8
8
  AuthFieldErrors,
9
9
  AuthFieldErrorsProvider,
10
10
  clearAuthFieldError,
11
- collectAuthFieldErrors
11
+ collectAuthFieldErrors,
12
+ focusFirstAuthFieldError
12
13
  } from '../auth/auth-field-errors';
14
+ import {
15
+ authFormMessage,
16
+ readCommerceError,
17
+ splitByKnownFields
18
+ } from '../auth/read-auth-error';
13
19
  import {
14
20
  AuthFormErrorProvider,
15
21
  containsAuthErrorBlock
16
22
  } from '../auth/form-error-context';
23
+ import {
24
+ AuthFormSuccessProvider,
25
+ containsAuthSuccessBlock
26
+ } from '../auth/form-success-context';
27
+ import {
28
+ AuthFormStateProvider,
29
+ AuthFormStateValue,
30
+ containsFormStateBlock,
31
+ showsInFormState
32
+ } from '../auth/form-state-context';
17
33
  import {
18
34
  clearFormSubmitState,
19
35
  publishFormSubmitState
@@ -24,8 +40,17 @@ import {
24
40
  setAuthStep
25
41
  } from '../auth/auth-flow-bus';
26
42
  import { renderComposedChildren } from './render-composed-children';
43
+ import { useHostModalCloser } from '../overlays/modal-host-context';
27
44
  import { BlockRendererProps } from './block-renderer-registry';
28
45
 
46
+ /**
47
+ * The only name this form submits under, and therefore the only one a
48
+ * field-level refusal can be hung on. Named as well as measured, because a
49
+ * composition whose CTA is a detached Button still has to attribute
50
+ * "no account with this address" to the address field.
51
+ */
52
+ const FORGOT_PASSWORD_FIELD_NAMES = ['email'] as const;
53
+
29
54
  /**
30
55
  * Functional auth block: e-mail input that triggers the password reset mail
31
56
  * through the v1 `forgotPassword` mutation. Behaviour is fixed (commerce sends
@@ -58,6 +83,19 @@ const ForgotPasswordBlock = ({
58
83
  getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
59
84
  );
60
85
 
86
+ /**
87
+ * Whether finishing closes the modal this block is inside. OFF by default
88
+ * here, because this block's success state is something the shopper is meant
89
+ * to read — see `useHostModalCloser`.
90
+ */
91
+ const closeHostModalOnSuccess = readToggle(
92
+ block.properties?.closeHostModalOnSuccess,
93
+ currentBreakpoint,
94
+ false
95
+ );
96
+ const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
97
+
98
+
61
99
  const submitLabel = prop('submitLabel', 'Send reset link');
62
100
  // Empty on purpose: without a busy label the submit caption stays put while
63
101
  // the request runs, which is what published blocks render today.
@@ -143,6 +181,21 @@ const ForgotPasswordBlock = ({
143
181
  .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
144
182
  .sort((a, b) => (a.order || 0) - (b.order || 0));
145
183
  const isComposed = childBlocks.length > 0;
184
+ /**
185
+ * Whether the composition draws the confirmation itself — either with a Form
186
+ * Success block carrying this form's sentence, or with a whole screen scoped
187
+ * to the success state in a Form State container.
188
+ *
189
+ * Two things follow: the built-in success copy stands down, and the form is
190
+ * NOT replaced by it — a block inside a form that has been swapped out is
191
+ * unmounted and would draw nothing at all, which is the whole reason the
192
+ * confirmation was unmanageable in the first place.
193
+ */
194
+ const successScreenIsComposed =
195
+ isComposed && containsFormStateBlock(childBlocks, 'success', currentBreakpoint);
196
+ const successIsAuthored =
197
+ successScreenIsComposed ||
198
+ (isComposed && containsAuthSuccessBlock(childBlocks));
146
199
 
147
200
  const [email, setEmail] = useState('');
148
201
  const [error, setError] = useState('');
@@ -185,10 +238,14 @@ const ForgotPasswordBlock = ({
185
238
  // Submitting inside the designer would send a real reset mail.
186
239
  if (isDesigner || isLoading) return;
187
240
 
241
+ // Held onto because `currentTarget` is only the form for as long as the
242
+ // handler runs, and the refusal is read after an await.
243
+ const formElement = event.currentTarget;
244
+
188
245
  // The form carries `noValidate`, so this is what stops an empty submit —
189
246
  // and it says so under the field rather than in a browser bubble.
190
247
  const { errors: nextFieldErrors, firstInvalid } = collectAuthFieldErrors(
191
- event.currentTarget,
248
+ formElement,
192
249
  { required: requiredMessage, invalid: invalidMessage }
193
250
  );
194
251
 
@@ -204,7 +261,7 @@ const ForgotPasswordBlock = ({
204
261
  // form the way every other composed auth form reads its fields, rather
205
262
  // than from state no child writes to.
206
263
  const address = isComposed
207
- ? String(new FormData(event.currentTarget).get('email') ?? '').trim()
264
+ ? String(new FormData(formElement).get('email') ?? '').trim()
208
265
  : email;
209
266
 
210
267
  try {
@@ -216,8 +273,28 @@ const ForgotPasswordBlock = ({
216
273
  if (successStep) {
217
274
  setAuthStep(successStep.flowId, successStep.stepKey);
218
275
  }
219
- } catch {
220
- setError(errorLabel);
276
+
277
+ // Contradictory on purpose-tolerant: a composition that names a success
278
+ // STEP and also asks to close gets the close, because a step inside a
279
+ // closed drawer is moot. Off by default — "check your inbox" is the
280
+ // point of this form's success state.
281
+ closeHostModal();
282
+ } catch (rejection) {
283
+ /**
284
+ * What commerce actually said, rather than "could not send" over a rate
285
+ * limit the shopper cannot see. RTK Query puts the body on `data`; a
286
+ * message naming the address goes under the field, which is where the
287
+ * shopper is looking, and anything form-level stays above it.
288
+ */
289
+ const reading = splitByKnownFields(
290
+ readCommerceError((rejection as { data?: unknown })?.data),
291
+ formElement,
292
+ FORGOT_PASSWORD_FIELD_NAMES
293
+ );
294
+
295
+ setFieldErrors((current) => ({ ...current, ...reading.fieldMessages }));
296
+ focusFirstAuthFieldError(formElement, reading.fieldMessages);
297
+ setError(authFormMessage(reading, errorLabel));
221
298
  }
222
299
  };
223
300
 
@@ -249,9 +326,26 @@ const ForgotPasswordBlock = ({
249
326
  </button>
250
327
  ) : null;
251
328
 
329
+ /** Finished — for real on a storefront, on the toggle in the canvas. */
330
+ const inSuccessState =
331
+ (isSuccess && !isDesigner) || (isDesigner && previewSuccess);
332
+
333
+ /**
334
+ * What the composition's Form State containers read. `busy` and `hasError`
335
+ * sit beside the screen rather than replacing it, so a refusal does not take
336
+ * the fields away with it — see `auth/form-state-context.tsx`.
337
+ */
338
+ const formStateValue: AuthFormStateValue = {
339
+ screen: inSuccessState ? 'success' : 'form',
340
+ busy: isLoading,
341
+ hasError: Boolean(error)
342
+ };
343
+
252
344
  // The success copy replaces the form so the shopper is not invited to submit
253
345
  // the same address twice; the root attribute is kept so styling still applies.
254
- if ((isSuccess && !isDesigner) || (isDesigner && previewSuccess)) {
346
+ // A composition that placed a Form Success block keeps its own layout
347
+ // instead — see `successIsAuthored`.
348
+ if (inSuccessState && !successIsAuthored) {
255
349
  return (
256
350
  <div
257
351
  data-forgot-password=""
@@ -279,6 +373,10 @@ const ForgotPasswordBlock = ({
279
373
 
280
374
  return (
281
375
  <AuthFormErrorProvider value={error}>
376
+ {/* The confirmation, for the block the composition placed for it. Empty
377
+ until the mail is away, so the block draws nothing before then. */}
378
+ <AuthFormSuccessProvider value={inSuccessState ? successLabel : ''}>
379
+ <AuthFormStateProvider value={formStateValue}>
282
380
  <AuthFieldErrorsProvider value={fieldErrors}>
283
381
  <form
284
382
  id={formHtmlId || undefined}
@@ -286,7 +384,9 @@ const ForgotPasswordBlock = ({
286
384
  data-forgot-password-composed=""
287
385
  // Fills its block wrapper; see the note in auth-step-block.tsx.
288
386
  className="w-full"
289
- data-forgot-password-state={isLoading ? 'busy' : 'idle'}
387
+ data-forgot-password-state={
388
+ inSuccessState ? 'success' : isLoading ? 'busy' : 'idle'
389
+ }
290
390
  {...partAttrs('form')}
291
391
  onSubmit={submit}
292
392
  // Our own message under the field instead of the browser's bubble
@@ -301,7 +401,17 @@ const ForgotPasswordBlock = ({
301
401
  }}
302
402
  >
303
403
  {renderComposedChildren({
304
- blocks: childBlocks,
404
+ // Only the containers this screen calls for; everything that is
405
+ // not a container is shared chrome and shows on all of them.
406
+ blocks: childBlocks.filter((childBlock) =>
407
+ showsInFormState({
408
+ block: childBlock,
409
+ state: formStateValue,
410
+ isDesigner,
411
+ selectedBlockId,
412
+ currentBreakpoint
413
+ })
414
+ ),
305
415
  placeholderId,
306
416
  sectionId,
307
417
  isDesigner,
@@ -322,7 +432,14 @@ const ForgotPasswordBlock = ({
322
432
  </div>
323
433
  )}
324
434
 
325
- {showSubmit && (
435
+ {/* Kept in the success state where the confirmation is only a
436
+ LINE — the address is still valid and the fields are still on
437
+ screen, so pressing it again is a RESEND, the affordance a
438
+ "check your inbox" screen normally wants. Dropped where a Form
439
+ State container owns the success screen: the fields are then
440
+ scoped away with it and this would be an orphan button under a
441
+ composition that never asked for it. */}
442
+ {showSubmit && !(inSuccessState && successScreenIsComposed) && (
326
443
  <button
327
444
  type="submit"
328
445
  data-forgot-password-submit=""
@@ -344,8 +461,14 @@ const ForgotPasswordBlock = ({
344
461
  {isLoading && busyLabel ? busyLabel : submitLabel}
345
462
  </button>
346
463
  )}
464
+
465
+ {/* The authored way back. Not when a container owns the screen:
466
+ the way onward is then part of the composition. */}
467
+ {inSuccessState && !successScreenIsComposed && backControl}
347
468
  </form>
348
469
  </AuthFieldErrorsProvider>
470
+ </AuthFormStateProvider>
471
+ </AuthFormSuccessProvider>
349
472
  </AuthFormErrorProvider>
350
473
  );
351
474
  }
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { readFormStateKey } from '../auth/form-state-context';
6
+ import { partAttrs } from '../utils';
7
+ import { renderComposedChildren } from './render-composed-children';
8
+ import { BlockRendererProps } from './block-renderer-registry';
9
+ import { DesignerPlaceholder } from './product-block-utils';
10
+
11
+ /**
12
+ * One SCREEN of a composed form, as a named wrapper whose contents are
13
+ * ordinary child blocks.
14
+ *
15
+ * This is what makes "the confirmation can be a line of text, or a whole
16
+ * component" true: the confirmation is whatever the designer puts in here — a
17
+ * Text block, or an icon and a heading and a Button, or the Form Success block
18
+ * that carries the form's own sentence. Same for the refusal, the busy moment,
19
+ * and the dead-link screen a password reset can land on. Before this, every
20
+ * state except the form itself was the form's own markup with one authored
21
+ * string in it.
22
+ *
23
+ * Deliberately shaped like `otp-phase` and `auth-step`, and for the same
24
+ * reason: the container decides NOTHING about its own visibility. The form it
25
+ * stands in draws the containers whose state matches — see
26
+ * `auth/form-state-context.tsx` — so dropped anywhere else this is just a
27
+ * labelled column whose children always show, which is what keeps it dressable
28
+ * on its own.
29
+ *
30
+ * Properties: formState.
31
+ * Parts: state.
32
+ */
33
+ const FormStateBlock = ({
34
+ block,
35
+ placeholderId,
36
+ sectionId,
37
+ isDesigner = false,
38
+ selectedBlockId,
39
+ currentBreakpoint = 'desktop'
40
+ }: BlockRendererProps) => {
41
+ const formState = readFormStateKey(block, currentBreakpoint);
42
+
43
+ const childBlocks = (block.blocks || [])
44
+ .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
45
+ .sort((a, b) => (a.order || 0) - (b.order || 0));
46
+
47
+ // An empty container draws nothing a shopper could see; in the canvas it has
48
+ // to say what it is waiting for, or adding one looks like nothing happened.
49
+ if (childBlocks.length === 0 && !isDesigner) return null;
50
+
51
+ return (
52
+ <div
53
+ data-form-state=""
54
+ // Fills its block wrapper; see the note in auth-step-block.tsx.
55
+ className="w-full"
56
+ data-form-state-key={formState || undefined}
57
+ {...partAttrs('state')}
58
+ style={{
59
+ display: 'flex',
60
+ flexDirection: 'column',
61
+ gap: 'var(--form-state-gap, 12px)'
62
+ }}
63
+ >
64
+ {childBlocks.length === 0 ? (
65
+ <DesignerPlaceholder
66
+ label={`Form state${
67
+ formState ? ` · ${formState}` : ''
68
+ } (drop its content here)`}
69
+ />
70
+ ) : (
71
+ renderComposedChildren({
72
+ blocks: childBlocks,
73
+ placeholderId,
74
+ sectionId,
75
+ isDesigner,
76
+ selectedBlockId,
77
+ currentBreakpoint
78
+ })
79
+ )}
80
+ </div>
81
+ );
82
+ };
83
+
84
+ export default FormStateBlock;
@@ -3,6 +3,8 @@
3
3
  import React, { useState } from 'react';
4
4
  import { useEmailSubscriptionMutation } from '@akinon/next/data/client/misc';
5
5
  import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { useHostModalCloser } from '../overlays/modal-host-context';
7
+ import { readToggle } from './product-block-utils';
6
8
  import { BlockRendererProps } from './block-renderer-registry';
7
9
 
8
10
  /** The wording the block has always shipped with; every label is editable. */
@@ -37,9 +39,22 @@ const NewsletterFormBlock = ({
37
39
  currentBreakpoint,
38
40
  fallback
39
41
  );
42
+
40
43
  return value === undefined || value === null ? fallback : String(value);
41
44
  };
42
45
 
46
+ /**
47
+ * Whether finishing closes the modal this block is inside. OFF by default
48
+ * here, because this block's success state is something the shopper is meant
49
+ * to read — see `useHostModalCloser`.
50
+ */
51
+ const closeHostModalOnSuccess = readToggle(
52
+ block.properties?.closeHostModalOnSuccess,
53
+ currentBreakpoint,
54
+ false
55
+ );
56
+ const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
57
+
43
58
  const placeholder = prop('placeholder', 'Your email address');
44
59
  const inputLabel = prop('inputLabel', '');
45
60
  const submitLabel = prop('submitLabel', 'Subscribe');
@@ -71,6 +86,7 @@ const NewsletterFormBlock = ({
71
86
  // form itself is the consent, so it is always sent as true.
72
87
  await subscribe({ email: value, subscribe_contract: true }).unwrap();
73
88
  setStatus('success');
89
+ closeHostModal();
74
90
  setEmail('');
75
91
  } catch {
76
92
  setErrorKind('request');