create-ailk 0.20.0 → 0.22.0

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 (222) hide show
  1. package/README.md +21 -1
  2. package/component-catalog.json +220 -8
  3. package/component-catalog.md +21 -9
  4. package/dist/add-module.js +9 -5
  5. package/dist/apply-design.d.ts +9 -0
  6. package/dist/apply-design.js +78 -31
  7. package/dist/derive-architecture.d.ts +8 -1
  8. package/dist/derive-architecture.js +38 -3
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/design-package.js +7 -2
  12. package/dist/lib/design-checks.d.ts +6 -1
  13. package/dist/lib/design-checks.js +23 -2
  14. package/dist/lib/env-catalog.js +5 -1
  15. package/dist/module-architecture.js +4 -2
  16. package/dist/programmatic.js +63 -25
  17. package/dist/site-chrome.d.ts +8 -0
  18. package/dist/site-chrome.js +17 -1
  19. package/dist/site-roster.d.ts +4 -2
  20. package/dist/site-roster.js +7 -5
  21. package/dist/spoke-checkout.d.ts +39 -18
  22. package/dist/spoke-checkout.js +177 -236
  23. package/dist/starter-grid.js +16 -0
  24. package/dist/surfaces.js +30 -14
  25. package/dist/sync-routes.d.ts +28 -0
  26. package/dist/sync-routes.js +124 -1
  27. package/dist/template-record.d.ts +4 -3
  28. package/dist/template-record.js +6 -3
  29. package/dist/topology.js +25 -1
  30. package/dist/update.d.ts +6 -1
  31. package/dist/update.js +125 -18
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/README.md +26 -25
  51. package/templates/apps/api/CLAUDE.md +20 -7
  52. package/templates/apps/api/jest.config.cjs +1 -0
  53. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  54. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  55. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  56. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  57. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  58. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  59. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  60. package/templates/apps/api/src/middleware/auth.ts +13 -6
  61. package/templates/apps/api/src/openapi/spec.ts +30 -3
  62. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  63. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  64. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  65. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  67. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  68. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  69. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  70. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  71. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  73. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  74. package/templates/apps/api/src/server.ts +28 -0
  75. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  76. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  77. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  78. package/templates/apps/api/src/services/consent.ts +95 -0
  79. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  80. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  81. package/templates/apps/api/src/services/person.ts +40 -8
  82. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  83. package/templates/apps/api/src/services/project-listings.ts +6 -1
  84. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  85. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  86. package/templates/apps/mcp/jest.config.cjs +1 -0
  87. package/templates/apps/web/.env.example +14 -0
  88. package/templates/apps/web/CLAUDE.md +139 -3
  89. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  90. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  91. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  92. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  93. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  94. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  95. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  96. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  97. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  98. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  99. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  100. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  102. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  103. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  104. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  105. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  106. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  114. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  115. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  116. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  117. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  119. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  120. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  121. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  122. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  123. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  124. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  125. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  126. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  127. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  128. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  129. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  130. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  131. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  132. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  133. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  134. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  135. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  136. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  137. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
  139. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
  148. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  149. package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
  150. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  151. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  154. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  155. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  156. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  157. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  160. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  161. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  162. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  163. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  164. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  165. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  166. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  167. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  168. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  169. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  170. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  171. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  172. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  173. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  174. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  175. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  176. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  177. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  178. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  179. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  180. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  181. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  182. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  183. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  184. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  185. package/templates/apps/web/auth.ts +28 -2
  186. package/templates/apps/web/jest.config.cjs +1 -0
  187. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  188. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  189. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  190. package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
  191. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
  192. package/templates/apps/web/lib/account-invite.ts +37 -0
  193. package/templates/apps/web/lib/checkout-config.ts +27 -0
  194. package/templates/apps/web/lib/checkout.ts +93 -0
  195. package/templates/apps/web/lib/consent-policies.ts +22 -0
  196. package/templates/apps/web/lib/deck-page.ts +192 -0
  197. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  198. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  199. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  200. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  201. package/templates/apps/web/lib/site-chrome.ts +2 -0
  202. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  203. package/templates/apps/web/messages/en.json +40 -12
  204. package/templates/apps/web/package.json +1 -0
  205. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  206. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  207. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  208. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  209. package/templates/database/CHANGELOG.md +47 -0
  210. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  211. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  212. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  213. package/templates/database/__tests__/seed.test.ts +72 -2
  214. package/templates/database/inbox/schema.prisma +17 -2
  215. package/templates/database/jest.config.cjs +1 -0
  216. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  217. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  218. package/templates/database/package.json +1 -1
  219. package/templates/database/seed.ts +60 -0
  220. package/templates/tools/jest/pin-timezone.cjs +34 -0
  221. package/versions.json +34 -34
  222. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
@@ -8,8 +8,12 @@
8
8
  * without calling api-client
9
9
  * - captureLeadAction returns generic error on API failure
10
10
  * - Parity: captureLeadAction is paired with ailkCaptureLead per ADR 0013
11
+ * - #8747: the lead is marked payload.site / payload.page and sends no
12
+ * siteId; a per-visitor limit runs before the write; an unreachable API is
13
+ * `unavailable`; the built-in form's source is `contact-form`
11
14
  */
12
15
 
16
+ import { __resetRateLimit } from "../../../../src/lib/rate-limit";
13
17
  import { captureLeadAction } from "../contact.actions.js";
14
18
 
15
19
  // Mock @working-theory/api-client. `visitorForwardHeaders` stays real (#7394)
@@ -29,6 +33,50 @@ jest.mock("next/headers", () => ({
29
33
  headers: jest.fn().mockResolvedValue({ get: () => null }),
30
34
  }));
31
35
 
36
+ // #8757 D8 — the thank-you's account link reads the request's locale and the
37
+ // `contact` messages through next-intl, and the generated sign-in provider.
38
+ // Both are mocked here, per case.
39
+ jest.mock("next-intl/server", () => {
40
+ const messages = require("../../../../messages/en.json") as {
41
+ contact: Record<string, unknown>;
42
+ };
43
+ return {
44
+ __esModule: true,
45
+ getLocale: jest.fn().mockResolvedValue("en"),
46
+ getTranslations: jest.fn(async (namespace: string) => {
47
+ if (namespace !== "contact") throw new Error(`unexpected namespace ${namespace}`);
48
+ return (key: string) => {
49
+ const value = messages.contact[key];
50
+ return typeof value === "string" ? value : `contact.${key}`;
51
+ };
52
+ }),
53
+ };
54
+ });
55
+
56
+ // #8757 D11 — a `checkout` magnet redirects after the write. Next's
57
+ // `redirect()` throws NEXT_REDIRECT; the mock throws the same way so a case
58
+ // can read the target and prove nothing ran after it.
59
+ const mockRedirect = jest.fn((url: string): never => {
60
+ throw new Error(`NEXT_REDIRECT:${url}`);
61
+ });
62
+ jest.mock("next/navigation", () => ({
63
+ __esModule: true,
64
+ redirect: (url: string) => mockRedirect(url),
65
+ }));
66
+
67
+ let mockSignInProvider: "site" | "hub" | "none" = "site";
68
+ let mockTopology: "hub" | "spoke" = "spoke";
69
+ // eslint-disable-next-line no-restricted-syntax -- #507: SIGN_IN_PROVIDER and TOPOLOGY are generated per-build constants (#8757 D8, D11), not runtime inputs; no env or HTTP surface selects them.
70
+ jest.mock("../../../../lib/topology.generated", () => ({
71
+ __esModule: true,
72
+ get SIGN_IN_PROVIDER() {
73
+ return mockSignInProvider;
74
+ },
75
+ get TOPOLOGY() {
76
+ return mockTopology;
77
+ },
78
+ }));
79
+
32
80
  import { leads, visitorForwardHeaders } from "@working-theory/api-client";
33
81
  import { headers } from "next/headers";
34
82
  const mockSubmit = leads.submit as jest.Mock;
@@ -37,6 +85,7 @@ const mockHeaders = headers as jest.Mock;
37
85
  afterEach(() => {
38
86
  jest.clearAllMocks();
39
87
  mockHeaders.mockResolvedValue({ get: () => null });
88
+ __resetRateLimit();
40
89
  });
41
90
 
42
91
  function makeFormData(fields: Record<string, string>): FormData {
@@ -51,6 +100,8 @@ const VALID_FIELDS = {
51
100
  name: "Jane Smith",
52
101
  email: "jane@example.com",
53
102
  message: "I am interested in your services.",
103
+ // #8661 D8 — the terms box is checked on the server too.
104
+ consent: "true",
54
105
  };
55
106
 
56
107
  const INITIAL_STATE = null;
@@ -149,25 +200,10 @@ describe("captureLeadAction — API error", () => {
149
200
  });
150
201
  });
151
202
 
152
- it("returns generic error when api-client throws", async () => {
203
+ // #4042 — a thrown submit leaves a server-side trace; #8747 — it is the
204
+ // API being unreachable, so the visitor is shown the contact email.
205
+ it("a thrown submit is logged with no submitted field, and is unavailable", async () => {
153
206
  mockSubmit.mockRejectedValueOnce(new Error("Network error"));
154
-
155
- const result = await captureLeadAction(
156
- INITIAL_STATE,
157
- makeFormData(VALID_FIELDS),
158
- );
159
-
160
- expect(result).toMatchObject({
161
- kind: "error",
162
- message: expect.any(String),
163
- });
164
- });
165
-
166
- // #4042 — the catch-all previously discarded a thrown error silently,
167
- // leaving zero server-side trace of a production integration failure.
168
- it("logs the thrown error server-side with a [captureLeadAction] prefix, with no PII from the submitted fields", async () => {
169
- const thrown = new Error("Network error");
170
- mockSubmit.mockRejectedValueOnce(thrown);
171
207
  const consoleErrorSpy = jest
172
208
  .spyOn(console, "error")
173
209
  .mockImplementation(() => undefined);
@@ -177,17 +213,14 @@ describe("captureLeadAction — API error", () => {
177
213
  makeFormData(VALID_FIELDS),
178
214
  );
179
215
 
180
- expect(consoleErrorSpy).toHaveBeenCalledWith("[captureLeadAction]", thrown);
216
+ expect(consoleErrorSpy).toHaveBeenCalled();
181
217
  const loggedArgs = consoleErrorSpy.mock.calls.flat();
182
218
  for (const value of Object.values(VALID_FIELDS)) {
183
219
  for (const arg of loggedArgs) {
184
220
  expect(String(arg)).not.toContain(value);
185
221
  }
186
222
  }
187
- expect(result).toMatchObject({
188
- kind: "error",
189
- message: expect.any(String),
190
- });
223
+ expect(result).toEqual({ kind: "unavailable" });
191
224
 
192
225
  consoleErrorSpy.mockRestore();
193
226
  });
@@ -269,7 +302,7 @@ describe("captureLeadAction — declared extra fields (#6730 D8a)", () => {
269
302
  source: string;
270
303
  payload: Record<string, unknown>;
271
304
  };
272
- expect(Object.keys(body.payload).sort()).toEqual(["message", "name"]);
305
+ expect(Object.keys(body.payload).sort()).toEqual(["message", "name", "page"]);
273
306
  expect(body.source).toBe("conversational-intake");
274
307
  });
275
308
 
@@ -369,3 +402,450 @@ describe("captureLeadAction — visitor forwarding (#7394)", () => {
369
402
  expect(mockSubmit.mock.calls[0]).toHaveLength(1);
370
403
  });
371
404
  });
405
+
406
+ // ─── #8661 D8 — the terms box and the source stamp ──────────────────────────
407
+
408
+ describe("captureLeadAction — consent and source (#8661 D8)", () => {
409
+ const { consent: _consent, ...UNTICKED } = VALID_FIELDS;
410
+
411
+ it("no consent → a consent field error, and nothing is sent", async () => {
412
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(UNTICKED));
413
+
414
+ expect(result).toEqual({
415
+ kind: "error",
416
+ fieldErrors: { consent: expect.any(String) },
417
+ });
418
+ expect(mockSubmit).not.toHaveBeenCalled();
419
+ });
420
+
421
+ it("no consent and no name → both errors", async () => {
422
+ const result = await captureLeadAction(
423
+ INITIAL_STATE,
424
+ makeFormData({ ...UNTICKED, name: "" }),
425
+ );
426
+
427
+ expect(result).toMatchObject({ kind: "error" });
428
+ const fieldErrors = (result as { fieldErrors: Record<string, string> }).fieldErrors;
429
+ expect(Object.keys(fieldErrors).sort()).toEqual(["consent", "name"]);
430
+ expect(mockSubmit).not.toHaveBeenCalled();
431
+ });
432
+
433
+ it("a ticked flow:contact post carries the source and the subject", async () => {
434
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
435
+
436
+ const result = await captureLeadAction(
437
+ INITIAL_STATE,
438
+ makeFormData({ ...VALID_FIELDS, source: "flow:contact", subject: "Support" }),
439
+ );
440
+
441
+ expect(result).toEqual({ kind: "success" });
442
+ expect(mockSubmit).toHaveBeenCalledTimes(1);
443
+ const body = mockSubmit.mock.calls[0][0] as {
444
+ source: string;
445
+ payload: Record<string, unknown>;
446
+ };
447
+ expect(body.source).toBe("flow:contact");
448
+ expect(body.payload.subject).toBe("Support");
449
+ });
450
+
451
+ it.each(["flow:other", "flow:onboarding", "admin", "WEB", "web"])(
452
+ "an unknown source (%s) is sent as contact-form",
453
+ async (source) => {
454
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
455
+
456
+ await captureLeadAction(INITIAL_STATE, makeFormData({ ...VALID_FIELDS, source }));
457
+
458
+ expect((mockSubmit.mock.calls[0][0] as { source: string }).source).toBe("contact-form");
459
+ },
460
+ );
461
+
462
+ // #8730 D2/D4 — the hidden source picks the stamp, never the gate: the
463
+ // intake captures consent itself and posts consent=true like the form.
464
+ it("source=conversational-intake without consent is refused, and nothing is sent", async () => {
465
+ const result = await captureLeadAction(
466
+ INITIAL_STATE,
467
+ makeFormData({ ...UNTICKED, source: "conversational-intake" }),
468
+ );
469
+
470
+ expect(result).toEqual({
471
+ kind: "error",
472
+ fieldErrors: { consent: expect.any(String) },
473
+ });
474
+ expect(mockSubmit).not.toHaveBeenCalled();
475
+ });
476
+
477
+ it.each(["contact-form", "conversational-intake", "flow:contact", "anything-else"])(
478
+ "no source value (%s) switches the check off",
479
+ async (source) => {
480
+ const result = await captureLeadAction(
481
+ INITIAL_STATE,
482
+ makeFormData({ ...UNTICKED, source }),
483
+ );
484
+
485
+ expect(result).toMatchObject({ kind: "error", fieldErrors: { consent: expect.any(String) } });
486
+ expect(mockSubmit).not.toHaveBeenCalled();
487
+ },
488
+ );
489
+
490
+ it("consent other than the string true is not acceptance", async () => {
491
+ const result = await captureLeadAction(
492
+ INITIAL_STATE,
493
+ makeFormData({ ...VALID_FIELDS, consent: "on" }),
494
+ );
495
+
496
+ expect(result).toMatchObject({ kind: "error", fieldErrors: { consent: expect.any(String) } });
497
+ expect(mockSubmit).not.toHaveBeenCalled();
498
+ });
499
+
500
+ it("an intake post with consent=true is sent as conversational-intake, carrying consent: true", async () => {
501
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
502
+
503
+ const result = await captureLeadAction(
504
+ INITIAL_STATE,
505
+ makeFormData({ ...VALID_FIELDS, source: "conversational-intake" }),
506
+ );
507
+
508
+ expect(result).toEqual({ kind: "success" });
509
+ const body = mockSubmit.mock.calls[0][0] as { source: string; consent: unknown };
510
+ expect(body.source).toBe("conversational-intake");
511
+ expect(body.consent).toBe(true);
512
+ });
513
+
514
+ it("every accepted post tells the API the terms were accepted, top level, never in payload", async () => {
515
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
516
+
517
+ await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
518
+
519
+ const body = mockSubmit.mock.calls[0][0] as {
520
+ consent: unknown;
521
+ payload: Record<string, unknown>;
522
+ };
523
+ expect(body.consent).toBe(true);
524
+ expect(body.payload).not.toHaveProperty("consent");
525
+ });
526
+
527
+ it("an API 400 consent_required is shown as the consent field error", async () => {
528
+ mockSubmit.mockResolvedValueOnce({
529
+ ok: false,
530
+ status: 400,
531
+ body: { error: "consent_required", message: "Accept the terms to send a contact message." },
532
+ });
533
+
534
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
535
+
536
+ expect(result).toEqual({
537
+ kind: "error",
538
+ fieldErrors: { consent: expect.any(String) },
539
+ });
540
+ });
541
+ });
542
+
543
+ // ─── #8747 — the spoke lead writer, the per-visitor limit, an unreachable API ─
544
+
545
+ describe("captureLeadAction — the site marker (#8747)", () => {
546
+ const priorSite = process.env.AILK_LEAD_SITE;
547
+
548
+ afterEach(() => {
549
+ if (priorSite === undefined) delete process.env.AILK_LEAD_SITE;
550
+ else process.env.AILK_LEAD_SITE = priorSite;
551
+ });
552
+
553
+ it("marks payload.site from AILK_LEAD_SITE and payload.page as contact, and sends no siteId", async () => {
554
+ process.env.AILK_LEAD_SITE = "wendihenry";
555
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
556
+
557
+ await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
558
+
559
+ const body = mockSubmit.mock.calls[0][0] as Record<string, unknown> & {
560
+ payload: Record<string, unknown>;
561
+ };
562
+ expect(body).not.toHaveProperty("siteId");
563
+ expect(body.payload).toMatchObject({ site: "wendihenry", page: "contact" });
564
+ });
565
+
566
+ it("a form field named site or page never overrides the marker", async () => {
567
+ process.env.AILK_LEAD_SITE = "wendihenry";
568
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
569
+
570
+ await captureLeadAction(
571
+ INITIAL_STATE,
572
+ makeFormData({ ...VALID_FIELDS, site: "attacker", page: "checkout" }),
573
+ );
574
+
575
+ const body = mockSubmit.mock.calls[0][0] as { payload: Record<string, unknown> };
576
+ expect(body.payload.site).toBe("wendihenry");
577
+ expect(body.payload.page).toBe("contact");
578
+ });
579
+
580
+ it("with AILK_LEAD_SITE unset, the lead carries no site at all", async () => {
581
+ delete process.env.AILK_LEAD_SITE;
582
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
583
+
584
+ await captureLeadAction(INITIAL_STATE, makeFormData({ ...VALID_FIELDS, site: "attacker" }));
585
+
586
+ const body = mockSubmit.mock.calls[0][0] as { payload: Record<string, unknown> };
587
+ expect(body.payload).not.toHaveProperty("site");
588
+ expect(body.payload.page).toBe("contact");
589
+ });
590
+ });
591
+
592
+ describe("captureLeadAction — the built-in form's source (#8747)", () => {
593
+ it("a post with no source is stamped contact-form", async () => {
594
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
595
+
596
+ await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
597
+
598
+ expect((mockSubmit.mock.calls[0][0] as { source: string }).source).toBe("contact-form");
599
+ });
600
+ });
601
+
602
+ describe("captureLeadAction — the per-visitor limit (#8747)", () => {
603
+ const visitor = (ip: string) => ({
604
+ get: (name: string) => (name === "x-forwarded-for" ? ip : null),
605
+ });
606
+
607
+ it("admits 30 posts from one visitor in ten minutes, and refuses the 31st without calling the API", async () => {
608
+ mockHeaders.mockResolvedValue(visitor("203.0.113.5"));
609
+ mockSubmit.mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
610
+
611
+ for (let i = 0; i < 30; i += 1) {
612
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
613
+ kind: "success",
614
+ });
615
+ }
616
+ expect(mockSubmit).toHaveBeenCalledTimes(30);
617
+
618
+ const refused = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
619
+
620
+ expect(refused).toMatchObject({ kind: "error", message: expect.any(String) });
621
+ expect(mockSubmit).toHaveBeenCalledTimes(30);
622
+ });
623
+
624
+ it("with no readable address, the limit is skipped rather than shared", async () => {
625
+ mockHeaders.mockResolvedValue({ get: () => null });
626
+ mockSubmit.mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
627
+
628
+ for (let i = 0; i < 31; i += 1) {
629
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
630
+ kind: "success",
631
+ });
632
+ }
633
+ expect(mockSubmit).toHaveBeenCalledTimes(31);
634
+ });
635
+
636
+ it("another visitor keeps its own budget", async () => {
637
+ mockSubmit.mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
638
+ mockHeaders.mockResolvedValue(visitor("203.0.113.5"));
639
+ for (let i = 0; i < 30; i += 1) {
640
+ await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
641
+ }
642
+
643
+ mockHeaders.mockResolvedValue(visitor("198.51.100.7"));
644
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
645
+ kind: "success",
646
+ });
647
+ });
648
+ });
649
+
650
+ describe("captureLeadAction — an unreachable API (#8747)", () => {
651
+ it.each([502, 504])("a %s is unavailable", async (status) => {
652
+ mockSubmit.mockResolvedValueOnce({ ok: false, status, body: null });
653
+
654
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
655
+ kind: "unavailable",
656
+ });
657
+ });
658
+
659
+ it("a thrown submit is unavailable", async () => {
660
+ mockSubmit.mockRejectedValueOnce(new TypeError("fetch failed"));
661
+ const spy = jest.spyOn(console, "error").mockImplementation(() => undefined);
662
+
663
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
664
+ kind: "unavailable",
665
+ });
666
+ spy.mockRestore();
667
+ });
668
+
669
+ it.each([400, 429, 500, 501, 503])("a %s that is not lead_capture_not_configured stays the generic error", async (status) => {
670
+ mockSubmit.mockResolvedValueOnce({ ok: false, status, body: { error: "x" } });
671
+
672
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
673
+ kind: "error",
674
+ message: expect.any(String),
675
+ });
676
+ });
677
+ });
678
+
679
+ // ── #8757 AC-5 — the thank-you carries the account link on `invite` only ───
680
+
681
+ describe("captureLeadAction — the account link (#8757 D8)", () => {
682
+ const { getLocale } = jest.requireMock("next-intl/server") as { getLocale: jest.Mock };
683
+ const INVITE_FIELDS = { ...VALID_FIELDS, email: "a@b.example" };
684
+ const LABEL = "Track this in your account";
685
+
686
+ function answers(next?: unknown): void {
687
+ mockSubmit.mockResolvedValueOnce({
688
+ ok: true,
689
+ body: {
690
+ id: "lead_abc123",
691
+ persisted: "db",
692
+ email: { attempted: true, sent: true },
693
+ ...(next !== undefined && { next }),
694
+ },
695
+ });
696
+ }
697
+
698
+ afterEach(() => {
699
+ mockSignInProvider = "site";
700
+ getLocale.mockResolvedValue("en");
701
+ });
702
+
703
+ it("provider site, locale en (served bare): invite → the sign-in link with next and the email", async () => {
704
+ answers({ completion: "invite" });
705
+
706
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toEqual({
707
+ kind: "success",
708
+ next: { href: "/sign-in?next=%2Faccount&email=a%40b.example", label: LABEL },
709
+ });
710
+ });
711
+
712
+ it("provider site, locale fr: both paths carry the prefix", async () => {
713
+ getLocale.mockResolvedValue("fr");
714
+ answers({ completion: "invite" });
715
+
716
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toEqual({
717
+ kind: "success",
718
+ next: { href: "/fr/sign-in?next=%2Ffr%2Faccount&email=a%40b.example", label: LABEL },
719
+ });
720
+ });
721
+
722
+ it("no next → exactly { kind: success }", async () => {
723
+ answers();
724
+
725
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toStrictEqual({
726
+ kind: "success",
727
+ });
728
+ });
729
+
730
+ it("provider hub → the hub-session sign-in start", async () => {
731
+ mockSignInProvider = "hub";
732
+ answers({ completion: "invite" });
733
+
734
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toEqual({
735
+ kind: "success",
736
+ next: { href: "/api/hub-session/sign-in?next=%2Faccount&email=a%40b.example", label: LABEL },
737
+ });
738
+ });
739
+
740
+ it("provider none → exactly { kind: success }", async () => {
741
+ mockSignInProvider = "none";
742
+ answers({ completion: "invite" });
743
+
744
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toStrictEqual({
745
+ kind: "success",
746
+ });
747
+ });
748
+
749
+ it("an unreadable locale still answers success, with the default locale's link", async () => {
750
+ getLocale.mockRejectedValueOnce(new Error("no request scope"));
751
+ answers({ completion: "invite" });
752
+
753
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toEqual({
754
+ kind: "success",
755
+ next: { href: "/sign-in?next=%2Faccount&email=a%40b.example", label: LABEL },
756
+ });
757
+ });
758
+
759
+ it("a malformed next is ignored → exactly { kind: success }", async () => {
760
+ answers({ completion: "invite", extra: true });
761
+
762
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toStrictEqual({
763
+ kind: "success",
764
+ });
765
+ });
766
+ });
767
+
768
+ describe("captureLeadAction — a checkout magnet redirects after the write (#8757 D11, AC-8)", () => {
769
+ function answers(next: unknown): void {
770
+ mockSubmit.mockResolvedValueOnce({
771
+ ok: true,
772
+ body: { id: "lead_abc123", persisted: "db", email: { skipped: true }, next },
773
+ });
774
+ }
775
+
776
+ it("next: checkout, retainer → Next's redirect to /api/checkout/retainer, after exactly one write", async () => {
777
+ answers({ completion: "checkout", checkoutOffering: "retainer" });
778
+
779
+ await expect(captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).rejects.toThrow(
780
+ "NEXT_REDIRECT:/api/checkout/retainer",
781
+ );
782
+ expect(mockSubmit).toHaveBeenCalledTimes(1);
783
+ expect(mockRedirect).toHaveBeenCalledTimes(1);
784
+ expect(mockRedirect).toHaveBeenCalledWith("/api/checkout/retainer");
785
+ expect(mockSubmit.mock.invocationCallOrder[0]!).toBeLessThan(
786
+ mockRedirect.mock.invocationCallOrder[0]!,
787
+ );
788
+ });
789
+
790
+ it.each([
791
+ ["a 500", { ok: false, status: 500, body: { error: "boom" } }, "error"],
792
+ ["an unreachable API", { ok: false, status: 0, body: null }, "unavailable"],
793
+ [
794
+ "lead capture not configured",
795
+ { ok: false, status: 503, body: { error: "lead_capture_not_configured" } },
796
+ "unavailable",
797
+ ],
798
+ ] as const)("a failed write (%s) → { kind: %s } and no redirect", async (_label, response, kind) => {
799
+ mockSubmit.mockResolvedValueOnce(response);
800
+
801
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
802
+ expect(result).toMatchObject({ kind });
803
+ expect(mockRedirect).not.toHaveBeenCalled();
804
+ });
805
+
806
+ it("a thrown write → { kind: unavailable } and no redirect", async () => {
807
+ mockSubmit.mockRejectedValueOnce(new Error("network"));
808
+
809
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
810
+ expect(result).toMatchObject({ kind: "unavailable" });
811
+ expect(mockRedirect).not.toHaveBeenCalled();
812
+ });
813
+
814
+ it.each(["../x", "/evil", "Retainer", "has_underscore", "a".repeat(41), ""])(
815
+ "checkoutOffering %j → exactly { kind: success } and no redirect",
816
+ async (checkoutOffering) => {
817
+ answers({ completion: "checkout", checkoutOffering });
818
+
819
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
820
+ kind: "success",
821
+ });
822
+ expect(mockRedirect).not.toHaveBeenCalled();
823
+ },
824
+ );
825
+
826
+ it("a build that is not a spoke (no standard checkout) → exactly { kind: success } and no redirect", async () => {
827
+ mockTopology = "hub";
828
+ try {
829
+ answers({ completion: "checkout", checkoutOffering: "retainer" });
830
+
831
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
832
+ kind: "success",
833
+ });
834
+ expect(mockSubmit).toHaveBeenCalledTimes(1);
835
+ expect(mockRedirect).not.toHaveBeenCalled();
836
+ } finally {
837
+ mockTopology = "spoke";
838
+ }
839
+ });
840
+
841
+ it("an invite still answers the account link and never redirects", async () => {
842
+ answers({ completion: "invite" });
843
+
844
+ const result = await captureLeadAction(
845
+ INITIAL_STATE,
846
+ makeFormData({ ...VALID_FIELDS, email: "a@b.example" }),
847
+ );
848
+ expect(result).toMatchObject({ kind: "success", next: { label: "Track this in your account" } });
849
+ expect(mockRedirect).not.toHaveBeenCalled();
850
+ });
851
+ });
@@ -55,6 +55,7 @@ jest.mock("@working-theory/ui", () => ({
55
55
  LeadCaptureForm: (props: {
56
56
  fields?: Array<{ name: string }>;
57
57
  submitLabel?: string;
58
+ text?: { submit?: string };
58
59
  fallbackEmail?: string;
59
60
  termsHref: string;
60
61
  }) =>
@@ -69,7 +70,8 @@ jest.mock("@working-theory/ui", () => ({
69
70
  React.createElement(
70
71
  "button",
71
72
  { "data-testid": "contact-submit" },
72
- props.submitLabel ?? "Send",
73
+ // The real block falls back to its copy's submit label.
74
+ props.submitLabel ?? props.text?.submit ?? "Send",
73
75
  ),
74
76
  ),
75
77
  ContactFallback: (props: { email?: string; text?: { noEmail?: string } }) =>
@@ -171,8 +173,15 @@ async function renderWith(sections: unknown[] | null) {
171
173
  return render(element);
172
174
  }
173
175
 
176
+ // #8731 — the page shares its two hub reads for a few seconds, per module
177
+ // instance. Each test starts an hour later so none sees another's read.
178
+ let clock = 1_800_000_000_000;
179
+ const nowSpy = jest.spyOn(Date, "now");
180
+
174
181
  beforeEach(() => {
175
182
  jest.clearAllMocks();
183
+ clock += 60 * 60 * 1000;
184
+ nowSpy.mockImplementation(() => clock);
176
185
  mockCaptureStatus.mockResolvedValue(ACCEPTING);
177
186
  mockContactEmail.mockReturnValue(undefined);
178
187
  });
@@ -181,14 +190,14 @@ describe("ContactPage — snapshot regression", () => {
181
190
  it("empty state renders deterministic HTML (renderPage empty-state + the form after it)", async () => {
182
191
  const { container } = await renderWith(null);
183
192
  expect(container.innerHTML).toMatchInlineSnapshot(
184
- `"<main id="main"><h1>Contact</h1><p data-testid="section-empty-state">Contact page content coming soon.</p></main><div data-testid="lead-capture-form" data-fields="" data-fallback-email="" data-terms-href="/en/terms"><button data-testid="contact-submit">Send</button></div>"`,
193
+ `"<main id="main"><h1>Contact</h1><p data-testid="section-empty-state">Contact page content coming soon.</p></main><div data-testid="lead-capture-form" data-fields="name,email,subject,message" data-fallback-email="" data-terms-href="/en/terms"><button data-testid="contact-submit">Send message</button></div>"`,
185
194
  );
186
195
  });
187
196
 
188
197
  it("populated state renders deterministic HTML (renderPage sections + the form after it)", async () => {
189
198
  const { container } = await renderWith([]);
190
199
  expect(container.innerHTML).toMatchInlineSnapshot(
191
- `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"ContactPage","inLanguage":"en","name":"Contact Us","description":"Get in touch.","mainEntity":{"@type":"ContactPoint","contactType":"customer support"}}</script><main id="main"><div><h1>Contact Us</h1><p data-testid="contact-description" class="mt-block-tight text-lead">Get in touch.</p></div><div data-testid="section-renderer" data-section-count="0"></div></main><div data-testid="lead-capture-form" data-fields="" data-fallback-email="" data-terms-href="/en/terms"><button data-testid="contact-submit">Send</button></div>"`,
200
+ `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"ContactPage","inLanguage":"en","name":"Contact Us","description":"Get in touch.","mainEntity":{"@type":"ContactPoint","contactType":"customer support"}}</script><main id="main"><div><h1>Contact Us</h1><p data-testid="contact-description" class="mt-block-tight text-lead">Get in touch.</p></div><div data-testid="section-renderer" data-section-count="0"></div></main><div data-testid="lead-capture-form" data-fields="name,email,subject,message" data-fallback-email="" data-terms-href="/en/terms"><button data-testid="contact-submit">Send message</button></div>"`,
192
201
  );
193
202
  });
194
203
  });