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
@@ -0,0 +1,77 @@
1
+ /**
2
+ * contactFormFromConfig — a Contact form magnet's config, as the shared
3
+ * `LeadCaptureForm` block renders it (#8661 D6).
4
+ *
5
+ * Reads the config's first step, which must be a `field` step (a config that
6
+ * opens with any other step is not a contact form). Answers `null` unless that
7
+ * step holds fields named `name` and `email`, because `captureLeadAction` needs both;
8
+ * the page then falls back (section fields, else the built-in config).
9
+ *
10
+ * Each `text`, `email`, `tel`, `url`, `textarea` or `select` field is mapped,
11
+ * in order. `name` and `email` sit side by side (`colSpan: 1`, the built-in
12
+ * pairing); every other field runs full width. The step's first `consent`
13
+ * field is the terms statement (its label), and its `copy.cta` the submit
14
+ * label. Other `consent` fields and `address` fields are not rendered.
15
+ *
16
+ * Copy resolves with the one LocalizedString rule every flow surface shares
17
+ * (`resolveLocalizedString`). Pure: no fetch, no React.
18
+ */
19
+ import type { ContactFormField } from "@working-theory/ui";
20
+ import { resolveLocalizedString, type FlowConfig } from "@working-theory/validation";
21
+
22
+ export interface ContactFormView {
23
+ fields: ContactFormField[];
24
+ /** The step's first consent field's label — `[Label](key)` tokens intact. */
25
+ consentStatement?: string;
26
+ /** The step's `copy.cta`. */
27
+ submitLabel?: string;
28
+ }
29
+
30
+ type FieldStep = Extract<FlowConfig["steps"][number], { type: "field" }>;
31
+ type FieldDef = FieldStep["fields"][number];
32
+
33
+ const RENDERED_TYPES = ["text", "email", "tel", "url", "textarea", "select"] as const;
34
+ type RenderedType = (typeof RENDERED_TYPES)[number];
35
+
36
+ function isRendered(type: FieldDef["inputType"]): type is RenderedType {
37
+ return (RENDERED_TYPES as readonly string[]).includes(type);
38
+ }
39
+
40
+ export function contactFormFromConfig(
41
+ config: FlowConfig,
42
+ locale: string,
43
+ ): ContactFormView | null {
44
+ const step = config.steps[0];
45
+ if (step?.type !== "field") return null;
46
+ const names = new Set(step.fields.map((f) => f.name));
47
+ if (!names.has("name") || !names.has("email")) return null;
48
+
49
+ const resolve = (value: Record<string, string>): string =>
50
+ resolveLocalizedString(value, locale, config.defaultLocale);
51
+
52
+ const fields: ContactFormField[] = [];
53
+ for (const field of step.fields) {
54
+ if (!isRendered(field.inputType)) continue;
55
+ fields.push({
56
+ name: field.name,
57
+ type: field.inputType,
58
+ label: resolve(field.copy.label),
59
+ required: field.required ?? false,
60
+ ...(field.copy.placeholder !== undefined && {
61
+ placeholder: resolve(field.copy.placeholder),
62
+ }),
63
+ ...(field.copy.help !== undefined && { helpText: resolve(field.copy.help) }),
64
+ ...(field.options !== undefined && {
65
+ options: field.options.map((o) => ({ value: o.value, label: resolve(o.label) })),
66
+ }),
67
+ colSpan: field.name === "name" || field.name === "email" ? 1 : 2,
68
+ });
69
+ }
70
+
71
+ const consent = step.fields.find((f) => f.inputType === "consent");
72
+ return {
73
+ fields,
74
+ ...(consent !== undefined && { consentStatement: resolve(consent.copy.label) }),
75
+ ...(step.copy.cta !== undefined && { submitLabel: resolve(step.copy.cta) }),
76
+ };
77
+ }
@@ -20,6 +20,7 @@ type ContactTextKey =
20
20
  | "successMessage"
21
21
  | "genericError"
22
22
  | "consentStatement"
23
+ | "consentRequired"
23
24
  | "unavailableHeading"
24
25
  | "unavailableBody"
25
26
  | "unavailableNoEmail";
@@ -43,6 +44,7 @@ export function contactFormText(t: ContactTranslator): LeadCaptureFormText {
43
44
  success: t("successMessage"),
44
45
  genericError: t("genericError"),
45
46
  consentStatement: t("consentStatement"),
47
+ consentRequired: t("consentRequired"),
46
48
  unavailableHeading: t("unavailableHeading"),
47
49
  unavailableBody: t("unavailableBody", KEEP_EMAIL_PLACEHOLDER),
48
50
  unavailableNoEmail: t("unavailableNoEmail"),
@@ -10,13 +10,26 @@
10
10
  * Canonical filename: contact.actions.ts (*.actions.ts per ADR 0013 / U0 spec
11
11
  * parity-test regex `ACTION_FILE_RE = /\.actions\.ts$/`).
12
12
  *
13
- * Rate-limit enforcement lives in apps/api; the honeypot is checked here.
13
+ * The honeypot is checked here, and so is a per-visitor limit (#8747): 30
14
+ * calls per 10 minutes per visitor address, before the write. From a server
15
+ * action the hub sees this site's egress address, so one visitor could
16
+ * otherwise drain the hub's per-IP budget for every visitor of the site. With
17
+ * no readable address the limit is skipped, never shared. The hub keeps its
18
+ * own per-IP limit on the route.
19
+ *
20
+ * The write (#8747): every lead goes through `writeLead`, the site's lead
21
+ * writer (`src/lib/lead-write.ts`), which marks it `payload.site =
22
+ * AILK_LEAD_SITE` and `payload.page = "contact"` (spread last, so no form field
23
+ * can override them) and sends no `siteId` — the API names the workspace
24
+ * itself, from the spoke key on a hub or its own site alias otherwise.
14
25
  *
15
26
  * Outcome (#6730 D8): the API's 503 `lead_capture_not_configured` — this
16
27
  * deployment has nowhere to put a lead — maps to `{ kind: "unavailable" }`,
17
- * which the shared `LeadCaptureForm` renders as the site's contact email.
18
- * Every other non-2xx and every thrown error is the generic `error`; only a
19
- * 2xx is ever `success`.
28
+ * which the shared `LeadCaptureForm` renders as the site's contact email. So
29
+ * does an API that cannot be reached (#8747): a thrown submit, a 502 or a 504
30
+ * — the message reached nobody, so the visitor is shown the email rather than
31
+ * a "try again". Every other non-2xx is the generic `error`; only a 2xx is
32
+ * ever `success`.
20
33
  *
21
34
  * Extra fields (#6730 D8a): a `contact-form` section can declare fields
22
35
  * beyond name/email/message/phone (a `select`, say). Every other string
@@ -33,8 +46,25 @@
33
46
  * second `*Action`-suffixed export, which would trip
34
47
  * `apps/mcp/__tests__/parity.test.ts`'s Server-Action↔MCP-tool 1:1 count for
35
48
  * a capability (lead capture) this repo already has an MCP twin for
36
- * (`ailkCaptureLead`). Absent (the real `<form>` never sets it) → defaults
37
- * to `"web"`, unchanged from before.
49
+ * (`ailkCaptureLead`). Absent (the real `<form>` never sets it) → the
50
+ * built-in form's one standard source, `"contact-form"` (#8747; it was
51
+ * `"web"`, and the sites disagreed).
52
+ *
53
+ * Source allow-list and terms (#8661 D8, #8730): `/contact` stamps a
54
+ * submission it drew from the Contact form magnet with a hidden `source` of
55
+ * `"flow:contact"`, so it lands as an Inbox submission of that magnet (it
56
+ * reaches the Needs-you queue through the submissions read, not the message
57
+ * list — `flow:<slug>` is never a message source). The field is
58
+ * visitor-controlled, so only the contact sources (`CONTACT_LEAD_SOURCES`:
59
+ * `"contact-form"`, `"conversational-intake"`, `"flow:contact"`) are kept;
60
+ * anything else is sent as `"contact-form"`, a message source, so no
61
+ * visitor-set value drops a real message out of Needs-you. The source picks
62
+ * the stamp and never the gate (#8730 D2): EVERY post must carry
63
+ * `consent=true` — the form's terms box, or the intake's own terms turn — or
64
+ * it answers a `consent` field error, merged with the schema's, and nothing
65
+ * is sent. An accepted post tells the API so (`consent: true`, which the API
66
+ * requires of a contact lead and records on it); an API `400
67
+ * consent_required` is shown as the same field error.
38
68
  *
39
69
  * Visitor forwarding (#7394): on a spoke whose API is a hub, the call
40
70
  * carries the visitor's IP and the spoke's `AILK_VISITOR_FORWARD_KEY`
@@ -43,16 +73,53 @@
43
73
  * of the spoke host's. No key, no client address, or headers that cannot be
44
74
  * read → the call is made with one argument, exactly as before; a failure to
45
75
  * read headers never blocks a submission.
76
+ *
77
+ * The account link (#8757 D8): after a successful write, the API's 201 may
78
+ * carry `next` (D6). `{ completion: "invite" }` — the magnet invites the
79
+ * submitter to their account — answers `{ kind: "success", next: { href,
80
+ * label } }`, the href from `accountInviteHref` (the request's locale, the
81
+ * submitter's email, this site's sign-in provider) and the label the
82
+ * `contact.trackInAccount` message. Anything else — no `next`, a site with no
83
+ * sign-in, an email the hint schema refuses, a locale or message that cannot
84
+ * be read — answers exactly `{ kind: "success" }`: a written lead is never
85
+ * reported as a failure.
86
+ *
87
+ * Continue to checkout (#8757 D11): `{ completion: "checkout",
88
+ * checkoutOffering }` — the magnet sends the submitter on to the offering's
89
+ * standard checkout — calls `redirect("/api/checkout/<checkoutOffering>")`
90
+ * (a Server Action redirect, a 303). It runs only after the write resolved
91
+ * `ok`, and outside any `try`, since `redirect()` throws NEXT_REDIRECT by
92
+ * design (`checkout.actions.ts`'s rule). The key is re-checked with
93
+ * `flowCheckoutOfferingSchema` first; one that fails answers `{ kind:
94
+ * "success" }`, as `lead` does. The standard checkout is served on a spoke
95
+ * only (`TOPOLOGY`), so any other build answers `{ kind: "success" }` too,
96
+ * never a redirect to a 404. A failed write never redirects.
46
97
  */
47
98
 
48
- import { leads, visitorForwardHeaders } from "@working-theory/api-client";
99
+ import { visitorForwardHeaders } from "@working-theory/api-client";
49
100
  import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
50
- import { submitLeadInputSchema } from "@working-theory/validation";
101
+ import {
102
+ CONTACT_LEAD_SOURCES,
103
+ flowCheckoutOfferingSchema,
104
+ leadSubmitNextSchema,
105
+ submitLeadInputSchema,
106
+ } from "@working-theory/validation";
51
107
  import { cookies, headers } from "next/headers";
108
+ import { redirect } from "next/navigation";
109
+ import { getLocale, getTranslations } from "next-intl/server";
52
110
 
111
+ import { accountInviteHref } from "../../../lib/account-invite";
53
112
  import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
113
+ import { TOPOLOGY } from "../../../lib/topology.generated";
114
+ import { writeLead } from "../../../src/lib/lead-write";
115
+ import { checkRateLimit, clientIpFromHeaders } from "../../../src/lib/rate-limit";
54
116
 
55
117
  const GENERIC_ERROR = "Something went wrong, please try again.";
118
+ const RATE_LIMITED = "Too many messages from here. Please try again in a few minutes.";
119
+
120
+ /** The per-visitor budget (#8747): the consumer sites' own values. */
121
+ const RATE_LIMIT_MAX_CALLS = 30;
122
+ const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
56
123
 
57
124
  /** Field names the action reads itself — never copied into `payload`. */
58
125
  const RESERVED_FIELDS = new Set([
@@ -65,6 +132,11 @@ const RESERVED_FIELDS = new Set([
65
132
  "source",
66
133
  ]);
67
134
  const MAX_EXTRA_FIELDS = 20;
135
+
136
+ /** The built-in form's source (#8747), and the one an unlisted value becomes. */
137
+ const DEFAULT_SOURCE = "contact-form";
138
+ /** The server's terms-error text. The form block shows its own copy for this key. */
139
+ const CONSENT_REQUIRED = "Please accept the terms to send your message.";
68
140
  const MAX_EXTRA_FIELD_LENGTH = 2000;
69
141
 
70
142
  /**
@@ -85,6 +157,16 @@ function extraFields(formData: FormData): Record<string, string> | null {
85
157
  return extra;
86
158
  }
87
159
 
160
+ /** A 400 whose body names `consent_required` (#8730). */
161
+ function isConsentRequired(res: { status: number; body: unknown }): boolean {
162
+ return (
163
+ res.status === 400 &&
164
+ typeof res.body === "object" &&
165
+ res.body !== null &&
166
+ (res.body as { error?: unknown }).error === "consent_required"
167
+ );
168
+ }
169
+
88
170
  /** A 503 whose body names `lead_capture_not_configured`. */
89
171
  function isNotConfigured(res: { status: number; body: unknown }): boolean {
90
172
  return (
@@ -95,6 +177,51 @@ function isNotConfigured(res: { status: number; body: unknown }): boolean {
95
177
  );
96
178
  }
97
179
 
180
+ /**
181
+ * The API could not be reached (#8747): `writeLead`'s `status: 0` (a thrown
182
+ * submit) or a gateway failure. A 503 that is not `lead_capture_not_configured`
183
+ * stays the generic error — the API answered.
184
+ */
185
+ function isApiUnreachable(res: { status: number }): boolean {
186
+ return res.status === 0 || res.status === 502 || res.status === 504;
187
+ }
188
+
189
+ /**
190
+ * #8757 D11 — the offering a `checkout` answer names, when its key passes the
191
+ * schema and this build serves the standard checkout (a spoke); else `null`.
192
+ */
193
+ function checkoutOfferingOf(next: unknown): string | null {
194
+ if (TOPOLOGY !== "spoke") return null;
195
+ const parsed = leadSubmitNextSchema.safeParse(next);
196
+ if (!parsed.success || parsed.data.completion !== "checkout") return null;
197
+ const key = flowCheckoutOfferingSchema.safeParse(parsed.data.checkoutOffering);
198
+ return key.success ? key.data : null;
199
+ }
200
+
201
+ /**
202
+ * #8757 D8 — the success state for a written lead: with the account link when
203
+ * the API answered `next: { completion: "invite" }` and this site can build
204
+ * one, else exactly `{ kind: "success" }`. Never throws.
205
+ */
206
+ async function successState(next: unknown, email: string): Promise<LeadCaptureFormState> {
207
+ const parsed = leadSubmitNextSchema.safeParse(next);
208
+ if (!parsed.success || parsed.data.completion !== "invite") return { kind: "success" };
209
+ let locale = "en";
210
+ try {
211
+ locale = await getLocale();
212
+ } catch {
213
+ // No request-scoped locale — the default locale's link.
214
+ }
215
+ const href = accountInviteHref(locale, email);
216
+ if (href === null) return { kind: "success" };
217
+ try {
218
+ const t = await getTranslations("contact");
219
+ return { kind: "success", next: { href, label: t("trackInAccount") } };
220
+ } catch {
221
+ return { kind: "success" };
222
+ }
223
+ }
224
+
98
225
  export async function captureLeadAction(
99
226
  _prevState: LeadCaptureFormState,
100
227
  formData: FormData,
@@ -112,15 +239,27 @@ export async function captureLeadAction(
112
239
  phone: formData.get("phone") ?? undefined,
113
240
  };
114
241
 
242
+ const posted = formData.get("source");
243
+ const source =
244
+ typeof posted === "string" && CONTACT_LEAD_SOURCES.has(posted) ? posted : DEFAULT_SOURCE;
245
+
246
+ // #8730 D2 — every source, the intake's included: it asks for the terms
247
+ // itself and posts consent=true like the form does.
248
+ const fieldErrors: Record<string, string> = {};
249
+ if (formData.get("consent") !== "true") {
250
+ fieldErrors.consent = CONSENT_REQUIRED;
251
+ }
252
+
115
253
  const parsed = submitLeadInputSchema.safeParse(raw);
116
254
  if (!parsed.success) {
117
- const fieldErrors: Record<string, string> = {};
118
255
  for (const issue of parsed.error.issues) {
119
256
  const field = String(issue.path[0] ?? "form");
120
257
  if (!fieldErrors[field]) {
121
258
  fieldErrors[field] = issue.message;
122
259
  }
123
260
  }
261
+ }
262
+ if (!parsed.success || Object.keys(fieldErrors).length > 0) {
124
263
  return { kind: "error", fieldErrors };
125
264
  }
126
265
 
@@ -128,29 +267,35 @@ export async function captureLeadAction(
128
267
  if (extra === null) return { kind: "error", message: GENERIC_ERROR };
129
268
 
130
269
  const { name, email, message, phone } = parsed.data;
131
- const siteId = process.env.AILK_SITE_ID ?? "default";
132
- const source = (formData.get("source") as string | null) || "web";
133
- const body = {
134
- siteId,
135
- source,
136
- email,
137
- payload: {
138
- ...extra,
139
- name,
140
- ...(message !== undefined && { message }),
141
- ...(phone !== undefined && { phone }),
142
- },
270
+ const payload = {
271
+ ...extra,
272
+ name,
273
+ ...(message !== undefined && { message }),
274
+ ...(phone !== undefined && { phone }),
143
275
  };
144
276
 
145
- let init: { headers: Record<string, string> } | undefined;
277
+ let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
146
278
  try {
147
- const forwarded = visitorForwardHeaders(
148
- await headers(),
149
- process.env.AILK_VISITOR_FORWARD_KEY,
150
- );
151
- if (forwarded) init = { headers: forwarded };
279
+ requestHeaders = await headers();
152
280
  } catch {
153
- // No request scope to read — submit without forwarding.
281
+ // No request scope to read — no forwarding, and the visitor is "unknown".
282
+ }
283
+
284
+ // No readable address: skip the per-visitor limit rather than put every
285
+ // visitor in one shared bucket a single caller could fill. The hub's own
286
+ // per-IP limit still applies.
287
+ const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
288
+ if (
289
+ visitor !== "unknown" &&
290
+ !checkRateLimit(`contact:${visitor}`, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
291
+ ) {
292
+ return { kind: "error", message: RATE_LIMITED };
293
+ }
294
+
295
+ let init: { headers: Record<string, string> } | undefined;
296
+ if (requestHeaders) {
297
+ const forwarded = visitorForwardHeaders(requestHeaders, process.env.AILK_VISITOR_FORWARD_KEY);
298
+ if (forwarded) init = { headers: forwarded };
154
299
  }
155
300
 
156
301
  // #8438 D12 — the banner's visitor id, so a later form submit links its
@@ -163,20 +308,18 @@ export async function captureLeadAction(
163
308
  // No request scope to read — submit with no visitor-link header.
164
309
  }
165
310
 
166
- try {
167
- const res = init ? await leads.submit(body, init) : await leads.submit(body);
168
- if (!res.ok) {
169
- if (isNotConfigured(res)) return { kind: "unavailable" };
170
- return { kind: "error", message: GENERIC_ERROR };
311
+ // writeLead never throws: a thrown submit is logged there by error name
312
+ // only, with no field values (#4042), and resolves as `status: 0`.
313
+ const res = await writeLead({ source, email, page: "contact", consent: true, payload }, init);
314
+ if (!res.ok) {
315
+ if (isConsentRequired(res)) {
316
+ return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
171
317
  }
172
- return { kind: "success" };
173
- } catch (err) {
174
- // Log server-side for observability (#4042, sibling of flow.actions.ts's
175
- // #3989 fix). The api-client's post() never attaches the request payload
176
- // to a thrown error (packages/api-client/src/http.ts), so logging `err`
177
- // itself is safe — no submitted form fields (name/email/message/phone)
178
- // are included.
179
- console.error("[captureLeadAction]", err);
318
+ if (isNotConfigured(res) || isApiUnreachable(res)) return { kind: "unavailable" };
180
319
  return { kind: "error", message: GENERIC_ERROR };
181
320
  }
321
+ // #8757 D11 — after the write, outside any try: redirect() throws by design.
322
+ const checkoutOffering = checkoutOfferingOf(res.body.next);
323
+ if (checkoutOffering !== null) redirect(`/api/checkout/${checkoutOffering}`);
324
+ return successState(res.body.next, email);
182
325
  }
@@ -11,6 +11,12 @@
11
11
  * ids ARE the submission adapter's field-mapping contract; renaming a
12
12
  * question id here requires updating that mapping too.
13
13
  *
14
+ * A fourth, closed question (`consent`, #8730 D2) asks for the terms before
15
+ * anything is sent. The engine records a tapped pill as its LABEL, so the
16
+ * wrapper treats the answer as acceptance only when it equals
17
+ * `copy.consentAccept` exactly; any other answer — the decline pill or typed
18
+ * text — sends nothing and shows the form, whose own terms box decides.
19
+ *
14
20
  * Copy is translated (next-intl "contact" namespace, "intake.*" keys —
15
21
  * see apps/web/messages/en.json) rather than hardcoded, consistent with
16
22
  * ContactForm's own `useTranslations("contact")` usage. The wrapper resolves
@@ -31,10 +37,18 @@ export interface ContactIntakeCopy {
31
37
  messagePrompt: string;
32
38
  emailAck: string;
33
39
  emailPrompt: string;
40
+ consentAck: string;
41
+ consentPrompt: string;
42
+ /** The accept pill's label — the one answer that is terms acceptance. */
43
+ consentAccept: string;
44
+ consentDecline: string;
34
45
  skipMessage: string;
35
46
  completionMessage: string;
36
47
  }
37
48
 
49
+ /** The accept option's value — its pill's test id is `…-pill-accept`. */
50
+ const INTAKE_CONSENT_ACCEPT = "accept";
51
+
38
52
  export function buildContactIntakeScript(
39
53
  copy: ContactIntakeCopy,
40
54
  ): IntakeScript {
@@ -59,6 +73,18 @@ export function buildContactIntakeScript(
59
73
  acknowledgement: copy.emailAck,
60
74
  prompt: copy.emailPrompt,
61
75
  },
76
+ // #8730 D2 — the intake asks for the terms itself. Only the accept
77
+ // pill's label is acceptance; the wrapper reads `consent` by that id.
78
+ {
79
+ id: "consent",
80
+ kind: "closed",
81
+ acknowledgement: copy.consentAck,
82
+ prompt: copy.consentPrompt,
83
+ options: [
84
+ { value: INTAKE_CONSENT_ACCEPT, label: copy.consentAccept },
85
+ { value: "decline", label: copy.consentDecline },
86
+ ],
87
+ },
62
88
  ],
63
89
  skip: { message: copy.skipMessage },
64
90
  completion: { message: copy.completionMessage },
@@ -11,10 +11,31 @@
11
11
  // API, an older API without the status route, or any other failed read is
12
12
  // treated as accepting: the form renders, and its submit reports honestly.
13
13
  //
14
+ // The form's fields (#8661 D6), first usable wins: the workspace's Contact
15
+ // form magnet (`flows.get("contact")`, the public flow read — the API resolves
16
+ // the workspace, the site alias keyless and a spoke by its key); else the
17
+ // page's `contact-form` section fields when it declares any; else the
18
+ // built-in form: the seeded magnet's own config, its name, email and message
19
+ // labels from the `contact` catalog. The submit label is the magnet's, else
20
+ // the section's; the success message is the section's. A magnet-driven
21
+ // post is stamped `source: "flow:contact"`, so it is an Inbox submission of
22
+ // the Contact form; the other two post as `contact-form` (#8747). The page is rendered on
23
+ // every request, but its two hub reads (the magnet and the capture status) are
24
+ // shared across requests for HUB_READ_TTL_MS (#8731): a visitor view carries no
25
+ // forwarding headers, so on a spoke every view would otherwise count against
26
+ // the spoke server's one per-IP limit at the hub. An Admin edit shows within
27
+ // that window. The terms statement
28
+ // links Terms, Privacy Policy and Working Agreement from a page-built map —
29
+ // a magnet label can name a link key, never an href.
30
+ //
31
+ // A `contact-form` section in the `column` variant (the minimalism Contact
32
+ // page, #8661 D12) gets the stacked form look and a Send that stays enabled,
33
+ // gating the terms box on submit instead.
34
+ //
14
35
  // The chat-style intake (ConversationalIntakeWrapper, #4534) remains
15
36
  // available to a site that prefers it. JSON-LD and translations handled by
16
37
  // renderPage.
17
- import { leads } from "@working-theory/api-client";
38
+ import { flows, leads } from "@working-theory/api-client";
18
39
  import type {
19
40
  getMdxSource as defaultGetMdxSource,
20
41
  getPageSections as defaultGetPageSections,
@@ -29,17 +50,26 @@ import {
29
50
  ContactFallback,
30
51
  type ContactFormField,
31
52
  LeadCaptureForm,
53
+ type LeadCaptureFormText,
32
54
  localeHref,
33
55
  } from "@working-theory/ui";
56
+ import {
57
+ CONTACT_FORM_FLOW_SLUG,
58
+ contactFormFlowConfig,
59
+ flowLeadSource,
60
+ type FlowConfig,
61
+ } from "@working-theory/validation";
34
62
  import type { Metadata } from "next";
35
63
  import { getTranslations } from "next-intl/server";
36
64
  import type { ReactElement } from "react";
37
65
 
66
+ import { contactFormFromConfig } from "./contact-form-magnet";
38
67
  import { contactFormText } from "./contact-form-text";
39
68
  import { captureLeadAction } from "./contact.actions";
40
69
  import { readSiteContactEmail } from "./site-contact-email";
41
70
 
42
- export const revalidate = 60;
71
+ // #8661 D6 — rendered on every request; the magnet read behind it is shared for HUB_READ_TTL_MS (#8731).
72
+ export const dynamic = "force-dynamic";
43
73
 
44
74
  interface Props {
45
75
  params: Promise<{ locale: string }>;
@@ -72,14 +102,72 @@ function sectionFields(section: ContactFormSection): ContactFormField[] {
72
102
  }));
73
103
  }
74
104
 
105
+ const BUILT_IN_LABELS = { name: "nameLabel", email: "emailLabel", message: "messageLabel" } as const;
106
+
107
+ /**
108
+ * The built-in form: the seeded magnet's own fields, with the labels the
109
+ * `contact` catalog carries (name, email, message) taken from it, so a
110
+ * translated site keeps its translated form when the magnet is absent.
111
+ */
112
+ function builtInFields(text: LeadCaptureFormText, locale: string): ContactFormField[] {
113
+ const view = contactFormFromConfig(contactFormFlowConfig, locale);
114
+ return (view?.fields ?? []).map((f) => {
115
+ const key = BUILT_IN_LABELS[f.name as keyof typeof BUILT_IN_LABELS];
116
+ return key ? { ...f, label: text[key] } : f;
117
+ });
118
+ }
119
+
120
+ /**
121
+ * How long the two hub reads are shared across requests (#8731). Short, so an
122
+ * Admin edit to the magnet shows within seconds; long enough that a burst of
123
+ * views makes one read of each, not one per view.
124
+ */
125
+ const HUB_READ_TTL_MS = 15_000;
126
+
127
+ const hubReads = new Map<string, { at: number; read: Promise<unknown> }>();
128
+
129
+ /**
130
+ * `read()`'s result, shared per instance for HUB_READ_TTL_MS. Concurrent calls
131
+ * share one in-flight read. A read that settles to `null` (any failed read,
132
+ * see the two readers below) is shared the same way, so a burst against an
133
+ * unreachable hub is not a burst of retries.
134
+ */
135
+ function sharedHubRead<T>(key: string, read: () => Promise<T>): Promise<T> {
136
+ const now = Date.now();
137
+ const hit = hubReads.get(key);
138
+ if (hit && now - hit.at < HUB_READ_TTL_MS) return hit.read as Promise<T>;
139
+ const fresh = read();
140
+ hubReads.set(key, { at: now, read: fresh });
141
+ return fresh;
142
+ }
143
+
75
144
  /** The API's capture status, or `null` for any failed read (see header). */
76
- async function readCaptureStatus(): Promise<CaptureStatus | null> {
77
- try {
78
- const res = await leads.captureStatus();
79
- return res.ok && res.body.ok === true ? res.body.data : null;
80
- } catch {
81
- return null;
82
- }
145
+ function readCaptureStatus(): Promise<CaptureStatus | null> {
146
+ return sharedHubRead("capture-status", async () => {
147
+ try {
148
+ const res = await leads.captureStatus();
149
+ return res.ok && res.body.ok === true ? res.body.data : null;
150
+ } catch {
151
+ return null;
152
+ }
153
+ });
154
+ }
155
+
156
+ /**
157
+ * The served Contact form magnet's config, or `null` for any failed read: a
158
+ * non-2xx (absent, draft, a build with no flow route) or a throw. The
159
+ * property access sits inside the `try`, so a client with no `flows` is a
160
+ * failed read too.
161
+ */
162
+ function readContactMagnet(): Promise<FlowConfig | null> {
163
+ return sharedHubRead("contact-magnet", async () => {
164
+ try {
165
+ const res = await flows.get(CONTACT_FORM_FLOW_SLUG);
166
+ return res.ok ? res.body : null;
167
+ } catch {
168
+ return null;
169
+ }
170
+ });
83
171
  }
84
172
 
85
173
  export async function generateMetadata({
@@ -95,9 +183,10 @@ export default async function Page({
95
183
  getPageSections,
96
184
  }: Props): Promise<ReactElement> {
97
185
  const { locale } = await params;
98
- const [status, t] = await Promise.all([
186
+ const [status, t, magnet] = await Promise.all([
99
187
  readCaptureStatus(),
100
188
  getTranslations({ locale, namespace: "contact" }),
189
+ readContactMagnet(),
101
190
  ]);
102
191
  const accepting = status?.contact ?? true;
103
192
  const contactEmail = readSiteContactEmail();
@@ -115,17 +204,32 @@ export default async function Page({
115
204
  formSection?.id ??
116
205
  (lead?.type === "hero" && lead.data.variant === "split" ? lead.id : undefined);
117
206
 
207
+ // #8661 D6 — the magnet's fields, else the section's, else the built-in form.
208
+ const served = magnet ? contactFormFromConfig(magnet, locale) : null;
209
+ const declared = formSection && formSection.data.fields.length > 0 ? formSection : undefined;
210
+ const fields: ContactFormField[] =
211
+ served?.fields ?? (declared ? sectionFields(declared) : builtInFields(text, locale));
212
+ // A magnet's own submit label wins; the section's copy fills what it leaves.
213
+ const submitLabel = served?.submitLabel ?? formSection?.data.submitLabel;
214
+ const successMessage = formSection?.data.successMessage;
215
+ const column = formSection?.data.variant === "column";
216
+
118
217
  const surface = accepting ? (
119
218
  <LeadCaptureForm
120
219
  action={captureLeadAction}
121
- text={text}
220
+ text={served?.consentStatement ? { ...text, consentStatement: served.consentStatement } : text}
122
221
  termsHref={localeHref(locale, "/terms")}
222
+ policies={{
223
+ terms: localeHref(locale, "/terms"),
224
+ privacy: localeHref(locale, "/privacy"),
225
+ working_agreement: localeHref(locale, "/working-agreement"),
226
+ }}
123
227
  fallbackEmail={fallbackEmail}
124
- {...(formSection && {
125
- fields: sectionFields(formSection),
126
- submitLabel: formSection.data.submitLabel,
127
- successMessage: formSection.data.successMessage,
128
- })}
228
+ fields={fields}
229
+ submitLabel={submitLabel}
230
+ successMessage={successMessage}
231
+ {...(served && { source: flowLeadSource(CONTACT_FORM_FLOW_SLUG) })}
232
+ {...(column && { look: "column" as const, consentGate: "on-submit" as const })}
129
233
  />
130
234
  ) : (
131
235
  <ContactFallback