create-ailk 0.21.0 → 0.23.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 (342) hide show
  1. package/component-catalog.json +186 -8
  2. package/component-catalog.md +9 -7
  3. package/dist/add-module.js +20 -14
  4. package/dist/add-route.d.ts +55 -1
  5. package/dist/add-route.js +294 -12
  6. package/dist/cli.js +27 -10
  7. package/dist/derive-architecture.d.ts +12 -3
  8. package/dist/derive-architecture.js +20 -8
  9. package/dist/design-package.js +7 -2
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +5 -5
  12. package/dist/lib/npm-auth.d.ts +9 -0
  13. package/dist/lib/npm-auth.js +48 -0
  14. package/dist/lib/scaffold-render-sweep.d.ts +3 -2
  15. package/dist/lib/scaffold-render-sweep.js +5 -8
  16. package/dist/module-architecture.js +99 -9
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/programmatic.js +19 -5
  20. package/dist/seed-flows.d.ts +4 -1
  21. package/dist/seed-flows.js +50 -4
  22. package/dist/site-chrome.d.ts +8 -0
  23. package/dist/site-chrome.js +17 -1
  24. package/dist/spoke-checkout.d.ts +65 -41
  25. package/dist/spoke-checkout.js +208 -430
  26. package/dist/starter-grid.d.ts +0 -3
  27. package/dist/starter-grid.js +24 -14
  28. package/dist/surfaces.js +52 -26
  29. package/dist/sync-routes.d.ts +38 -1
  30. package/dist/sync-routes.js +187 -13
  31. package/dist/template-record.d.ts +4 -3
  32. package/dist/template-record.js +8 -4
  33. package/dist/topology.js +25 -1
  34. package/dist/update.js +15 -0
  35. package/package.json +3 -3
  36. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  37. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  38. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  39. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  41. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  43. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  46. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  48. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  49. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  50. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  51. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  52. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  53. package/templates/.env.example +9 -7
  54. package/templates/.github/workflows/ci.yml +13 -0
  55. package/templates/README.md +1 -0
  56. package/templates/_gitignore +4 -0
  57. package/templates/apps/api/.env.example +27 -0
  58. package/templates/apps/api/CLAUDE.md +87 -17
  59. package/templates/apps/api/package.json +1 -0
  60. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  61. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  62. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  63. package/templates/apps/api/src/config/index.ts +109 -0
  64. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  65. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  66. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  67. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  68. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  69. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  70. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  71. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  72. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  73. package/templates/apps/api/src/lib/stripe.ts +74 -4
  74. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  75. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  76. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  77. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  78. package/templates/apps/api/src/middleware/auth.ts +13 -6
  79. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  80. package/templates/apps/api/src/openapi/spec.ts +33 -3
  81. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  82. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  83. package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
  84. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  85. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
  86. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  87. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  88. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  89. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  90. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  91. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  92. package/templates/apps/api/src/routes/consent/account.ts +268 -0
  93. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  94. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  95. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  96. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  97. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  98. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  99. package/templates/apps/api/src/routes/content/index.ts +16 -2
  100. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  101. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  102. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  103. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  104. package/templates/apps/api/src/routes/leads/index.ts +164 -7
  105. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  106. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  107. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  108. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  109. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
  110. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  111. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  112. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  113. package/templates/apps/api/src/routes/webhooks/stripe.ts +245 -36
  114. package/templates/apps/api/src/server.ts +163 -46
  115. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  116. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
  117. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  118. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1383 -133
  119. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  120. package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
  121. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
  122. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  123. package/templates/apps/api/src/services/consent.ts +95 -0
  124. package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
  125. package/templates/apps/api/src/services/entitlements.ts +157 -1
  126. package/templates/apps/api/src/services/lead-routing.ts +431 -47
  127. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  128. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
  129. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  130. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
  131. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  132. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
  133. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
  134. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  135. package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
  136. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
  137. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  138. package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
  139. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  140. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  141. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  142. package/templates/apps/api/src/services/person.ts +164 -17
  143. package/templates/apps/api/src/services/plan-offerings.ts +125 -0
  144. package/templates/apps/api/src/services/public-site-resolver.ts +27 -15
  145. package/templates/apps/mcp/CLAUDE.md +22 -14
  146. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  147. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  148. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  149. package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
  150. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  151. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  152. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  153. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  154. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  155. package/templates/apps/mcp/src/server.ts +5 -0
  156. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  157. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  158. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  159. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  160. package/templates/apps/web/.env.example +24 -31
  161. package/templates/apps/web/CLAUDE.md +393 -9
  162. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  163. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  164. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  165. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  166. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  167. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  168. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  169. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  170. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  171. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  172. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  173. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  174. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  175. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  176. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  177. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  178. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  179. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  183. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  184. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  185. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  186. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  187. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  188. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  189. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  190. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  191. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  192. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  193. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  194. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  195. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  196. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +295 -0
  197. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +120 -0
  198. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +23 -0
  199. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +319 -0
  200. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  201. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  202. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +141 -0
  203. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +424 -0
  204. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +72 -11
  205. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  206. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  207. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  208. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  209. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  210. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  211. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  212. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
  213. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  214. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +160 -13
  215. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +466 -36
  216. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
  217. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +172 -57
  218. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  219. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -60
  220. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  221. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  222. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +341 -4
  223. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  224. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  225. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  226. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  227. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  228. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  229. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  230. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  231. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
  232. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  233. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  234. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  235. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  236. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  237. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  238. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  239. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  240. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  241. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  242. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  243. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  244. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  245. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  246. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  247. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  248. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  249. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  250. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  251. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  252. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  253. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  254. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  255. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  256. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  257. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  258. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  259. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  260. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  261. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  262. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  263. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  264. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  265. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  266. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  267. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  268. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  269. package/templates/apps/web/auth.ts +28 -2
  270. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  271. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  272. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  273. package/templates/apps/web/lib/__tests__/deck-page.test.ts +7 -1
  274. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  275. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
  276. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  277. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  278. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +101 -25
  279. package/templates/apps/web/lib/account-invite.ts +37 -0
  280. package/templates/apps/web/lib/auth-routes.ts +36 -2
  281. package/templates/apps/web/lib/checkout-config.ts +16 -0
  282. package/templates/apps/web/lib/consent-policies.ts +22 -0
  283. package/templates/apps/web/lib/deck-page.ts +7 -0
  284. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  285. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  286. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  287. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  288. package/templates/apps/web/lib/magnet-routes.ts +42 -0
  289. package/templates/apps/web/lib/modules.generated.ts +9 -0
  290. package/templates/apps/web/lib/newsletter.actions.ts +202 -0
  291. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  292. package/templates/apps/web/lib/posting-title.ts +17 -0
  293. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  294. package/templates/apps/web/lib/sign-up-agreements.ts +449 -0
  295. package/templates/apps/web/lib/site-chrome.ts +2 -0
  296. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  297. package/templates/apps/web/messages/en.json +71 -20
  298. package/templates/apps/web/middleware.ts +12 -2
  299. package/templates/apps/web/package.json +1 -0
  300. package/templates/apps/web/security-headers.config.ts +17 -0
  301. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  302. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  303. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  304. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  305. package/templates/content/en/BlogList/blog.mdx +4 -71
  306. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  307. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  308. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  309. package/templates/content/en/BookCall/start-project.mdx +1 -17
  310. package/templates/content/en/ContactPage/contact.mdx +6 -63
  311. package/templates/content/en/HomePage/home.mdx +3 -21
  312. package/templates/database/CHANGELOG.md +69 -0
  313. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  314. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  315. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  316. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  317. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  318. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  319. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  320. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  321. package/templates/database/__tests__/seed.test.ts +64 -1
  322. package/templates/database/auth/schema.prisma +4 -0
  323. package/templates/database/config/schema.prisma +91 -0
  324. package/templates/database/inbox/schema.prisma +17 -2
  325. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  326. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  327. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  328. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  329. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  330. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  331. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  332. package/templates/database/package.json +1 -1
  333. package/templates/database/seed.ts +62 -3
  334. package/versions.json +35 -35
  335. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  336. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
  337. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  338. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  339. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  340. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  341. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  342. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -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,17 +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).
38
52
  *
39
- * Source allow-list and terms (#8661 D8): `/contact` stamps a submission it
40
- * drew from the Contact form magnet with a hidden `source` of
41
- * `"flow:contact"`, so it lands as an Inbox submission of that magnet. The
42
- * field is visitor-controlled, so only `"web"`, `"conversational-intake"` and
43
- * `"flow:contact"` are kept; anything else is sent as `"web"`. And the terms
44
- * box is checked here, not only by the client: unless the source is the
45
- * intake (which renders no box), a post without `consent=true` answers a
46
- * `consent` field error, merged with the schema's, and nothing is sent.
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.
47
68
  *
48
69
  * Visitor forwarding (#7394): on a spoke whose API is a hub, the call
49
70
  * carries the visitor's IP and the spoke's `AILK_VISITOR_FORWARD_KEY`
@@ -52,20 +73,53 @@
52
73
  * of the spoke host's. No key, no client address, or headers that cannot be
53
74
  * read → the call is made with one argument, exactly as before; a failure to
54
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.
55
97
  */
56
98
 
57
- import { leads, visitorForwardHeaders } from "@working-theory/api-client";
99
+ import { visitorForwardHeaders } from "@working-theory/api-client";
58
100
  import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
59
101
  import {
60
- CONTACT_FORM_FLOW_SLUG,
61
- flowLeadSource,
102
+ CONTACT_LEAD_SOURCES,
103
+ flowCheckoutOfferingSchema,
104
+ leadSubmitNextSchema,
62
105
  submitLeadInputSchema,
63
106
  } from "@working-theory/validation";
64
107
  import { cookies, headers } from "next/headers";
108
+ import { redirect } from "next/navigation";
109
+ import { getLocale, getTranslations } from "next-intl/server";
65
110
 
111
+ import { accountInviteHref } from "../../../lib/account-invite";
66
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";
67
116
 
68
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;
69
123
 
70
124
  /** Field names the action reads itself — never copied into `payload`. */
71
125
  const RESERVED_FIELDS = new Set([
@@ -79,13 +133,8 @@ const RESERVED_FIELDS = new Set([
79
133
  ]);
80
134
  const MAX_EXTRA_FIELDS = 20;
81
135
 
82
- /** The only sources a form post may stamp (#8661 D8); anything else is `"web"`. */
83
- const ALLOWED_SOURCES = new Set([
84
- "web",
85
- "conversational-intake",
86
- flowLeadSource(CONTACT_FORM_FLOW_SLUG),
87
- ]);
88
- const INTAKE_SOURCE = "conversational-intake";
136
+ /** The built-in form's source (#8747), and the one an unlisted value becomes. */
137
+ const DEFAULT_SOURCE = "contact-form";
89
138
  /** The server's terms-error text. The form block shows its own copy for this key. */
90
139
  const CONSENT_REQUIRED = "Please accept the terms to send your message.";
91
140
  const MAX_EXTRA_FIELD_LENGTH = 2000;
@@ -108,6 +157,16 @@ function extraFields(formData: FormData): Record<string, string> | null {
108
157
  return extra;
109
158
  }
110
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
+
111
170
  /** A 503 whose body names `lead_capture_not_configured`. */
112
171
  function isNotConfigured(res: { status: number; body: unknown }): boolean {
113
172
  return (
@@ -118,6 +177,54 @@ function isNotConfigured(res: { status: number; body: unknown }): boolean {
118
177
  );
119
178
  }
120
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(
207
+ next: unknown,
208
+ email: string,
209
+ ): Promise<Extract<LeadCaptureFormState, { kind: "success" }>> {
210
+ const parsed = leadSubmitNextSchema.safeParse(next);
211
+ if (!parsed.success || parsed.data.completion !== "invite") return { kind: "success" };
212
+ let locale = "en";
213
+ try {
214
+ locale = await getLocale();
215
+ } catch {
216
+ // No request-scoped locale — the default locale's link.
217
+ }
218
+ const href = accountInviteHref(locale, email);
219
+ if (href === null) return { kind: "success" };
220
+ try {
221
+ const t = await getTranslations("contact");
222
+ return { kind: "success", next: { href, label: t("trackInAccount") } };
223
+ } catch {
224
+ return { kind: "success" };
225
+ }
226
+ }
227
+
121
228
  export async function captureLeadAction(
122
229
  _prevState: LeadCaptureFormState,
123
230
  formData: FormData,
@@ -137,10 +244,12 @@ export async function captureLeadAction(
137
244
 
138
245
  const posted = formData.get("source");
139
246
  const source =
140
- typeof posted === "string" && ALLOWED_SOURCES.has(posted) ? posted : "web";
247
+ typeof posted === "string" && CONTACT_LEAD_SOURCES.has(posted) ? posted : DEFAULT_SOURCE;
141
248
 
249
+ // #8730 D2 — every source, the intake's included: it asks for the terms
250
+ // itself and posts consent=true like the form does.
142
251
  const fieldErrors: Record<string, string> = {};
143
- if (source !== INTAKE_SOURCE && formData.get("consent") !== "true") {
252
+ if (formData.get("consent") !== "true") {
144
253
  fieldErrors.consent = CONSENT_REQUIRED;
145
254
  }
146
255
 
@@ -161,28 +270,35 @@ export async function captureLeadAction(
161
270
  if (extra === null) return { kind: "error", message: GENERIC_ERROR };
162
271
 
163
272
  const { name, email, message, phone } = parsed.data;
164
- const siteId = process.env.AILK_SITE_ID ?? "default";
165
- const body = {
166
- siteId,
167
- source,
168
- email,
169
- payload: {
170
- ...extra,
171
- name,
172
- ...(message !== undefined && { message }),
173
- ...(phone !== undefined && { phone }),
174
- },
273
+ const payload = {
274
+ ...extra,
275
+ name,
276
+ ...(message !== undefined && { message }),
277
+ ...(phone !== undefined && { phone }),
175
278
  };
176
279
 
177
- let init: { headers: Record<string, string> } | undefined;
280
+ let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
178
281
  try {
179
- const forwarded = visitorForwardHeaders(
180
- await headers(),
181
- process.env.AILK_VISITOR_FORWARD_KEY,
182
- );
183
- if (forwarded) init = { headers: forwarded };
282
+ requestHeaders = await headers();
184
283
  } catch {
185
- // No request scope to read — submit without forwarding.
284
+ // No request scope to read — no forwarding, and the visitor is "unknown".
285
+ }
286
+
287
+ // No readable address: skip the per-visitor limit rather than put every
288
+ // visitor in one shared bucket a single caller could fill. The hub's own
289
+ // per-IP limit still applies.
290
+ const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
291
+ if (
292
+ visitor !== "unknown" &&
293
+ !checkRateLimit(`contact:${visitor}`, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
294
+ ) {
295
+ return { kind: "error", message: RATE_LIMITED };
296
+ }
297
+
298
+ let init: { headers: Record<string, string> } | undefined;
299
+ if (requestHeaders) {
300
+ const forwarded = visitorForwardHeaders(requestHeaders, process.env.AILK_VISITOR_FORWARD_KEY);
301
+ if (forwarded) init = { headers: forwarded };
186
302
  }
187
303
 
188
304
  // #8438 D12 — the banner's visitor id, so a later form submit links its
@@ -195,20 +311,19 @@ export async function captureLeadAction(
195
311
  // No request scope to read — submit with no visitor-link header.
196
312
  }
197
313
 
198
- try {
199
- const res = init ? await leads.submit(body, init) : await leads.submit(body);
200
- if (!res.ok) {
201
- if (isNotConfigured(res)) return { kind: "unavailable" };
202
- return { kind: "error", message: GENERIC_ERROR };
314
+ // writeLead never throws: a thrown submit is logged there by error name
315
+ // only, with no field values (#4042), and resolves as `status: 0`.
316
+ const res = await writeLead({ source, email, page: "contact", consent: true, payload }, init);
317
+ if (!res.ok) {
318
+ if (isConsentRequired(res)) {
319
+ return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
203
320
  }
204
- return { kind: "success" };
205
- } catch (err) {
206
- // Log server-side for observability (#4042, sibling of flow.actions.ts's
207
- // #3989 fix). The api-client's post() never attaches the request payload
208
- // to a thrown error (packages/api-client/src/http.ts), so logging `err`
209
- // itself is safe — no submitted form fields (name/email/message/phone)
210
- // are included.
211
- console.error("[captureLeadAction]", err);
321
+ if (isNotConfigured(res) || isApiUnreachable(res)) return { kind: "unavailable" };
212
322
  return { kind: "error", message: GENERIC_ERROR };
213
323
  }
324
+ // #8757 D11 — after the write, outside any try: redirect() throws by design.
325
+ const checkoutOffering = checkoutOfferingOf(res.body.next);
326
+ if (checkoutOffering !== null) redirect(`/api/checkout/${checkoutOffering}`);
327
+ // #8847 D7 — the parsed name, which the magnet's ending fills `{name}` with.
328
+ return { ...(await successState(res.body.next, email)), name };
214
329
  }
@@ -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,18 +11,25 @@
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 `web`. The page is rendered on
23
- // every request, so an Admin edit shows on the next one. The terms statement
24
- // links Terms, Privacy Policy and Working Agreement from a page-built map —
25
- // a magnet label can name a link key, never an href.
14
+ // The form (#8661 D6, #8847 D7), first usable wins: the workspace's Contact
15
+ // form magnet (`flows.get("contact")`, the public flow read — the API
16
+ // resolves the workspace, the site alias keyless and a spoke by its key);
17
+ // else the page's `contact-form` section fields when it declares any; else
18
+ // the built-in form: the seeded magnet's own config, its name, email and
19
+ // message labels from the `contact` catalog. Every visible fact on the page
20
+ // comes from the magnet (#8847): its field step's title is the page's H1 and
21
+ // `<title>`, its body the line under it (`framedSources`), and its ending
22
+ // the success message. The built-in config stands in when the hub cannot be
23
+ // read, so a fresh deploy looks the same either way. The section's own
24
+ // `successMessage` and `submitLabel` apply only when its own fields are
25
+ // used. A magnet-driven post is stamped `source: "flow:contact"`, so it is
26
+ // an Inbox submission of the Contact form; the other two post as
27
+ // `contact-form` (#8747). The page is rendered on every request, but its two
28
+ // hub reads (the magnet and the capture status) are shared across requests
29
+ // for HUB_READ_TTL_MS (`lib/in-page-magnet.ts`, #8731). The terms statement
30
+ // links Terms and Privacy Policy from a page-built map — a magnet label can
31
+ // name a link key, never an href. Under the form, "Prefer email?" and the
32
+ // `_site.mdx` contact email, when the site has one.
26
33
  //
27
34
  // A `contact-form` section in the `column` variant (the minimalism Contact
28
35
  // page, #8661 D12) gets the stacked form look and a Send that stays enabled,
@@ -31,7 +38,6 @@
31
38
  // The chat-style intake (ConversationalIntakeWrapper, #4534) remains
32
39
  // available to a site that prefers it. JSON-LD and translations handled by
33
40
  // renderPage.
34
- import { flows, leads } from "@working-theory/api-client";
35
41
  import type {
36
42
  getMdxSource as defaultGetMdxSource,
37
43
  getPageSections as defaultGetPageSections,
@@ -49,22 +55,30 @@ import {
49
55
  type LeadCaptureFormText,
50
56
  localeHref,
51
57
  } from "@working-theory/ui";
58
+ import { Link, Text } from "@working-theory/ui/primitives";
52
59
  import {
53
60
  CONTACT_FORM_FLOW_SLUG,
54
61
  contactFormFlowConfig,
55
62
  flowLeadSource,
56
- type FlowConfig,
57
63
  } from "@working-theory/validation";
58
64
  import type { Metadata } from "next";
59
65
  import { getTranslations } from "next-intl/server";
60
66
  import type { ReactElement } from "react";
61
67
 
62
- import { contactFormFromConfig } from "./contact-form-magnet";
68
+ import {
69
+ framedSources,
70
+ inPageView,
71
+ readCaptureStatus,
72
+ readServedMagnet,
73
+ type InPageView,
74
+ } from "../../../lib/in-page-magnet";
75
+
76
+ import { contactFormFromConfig, type ContactFormView } from "./contact-form-magnet";
63
77
  import { contactFormText } from "./contact-form-text";
64
78
  import { captureLeadAction } from "./contact.actions";
65
79
  import { readSiteContactEmail } from "./site-contact-email";
66
80
 
67
- // #8661 D6 — the Contact form magnet is read on every request.
81
+ // #8661 D6 — rendered on every request; the magnet read behind it is shared for HUB_READ_TTL_MS (#8731, lib/in-page-magnet.ts).
68
82
  export const dynamic = "force-dynamic";
69
83
 
70
84
  interface Props {
@@ -73,11 +87,6 @@ interface Props {
73
87
  getPageSections?: typeof defaultGetPageSections;
74
88
  }
75
89
 
76
- type CaptureStatus = Extract<
77
- Awaited<ReturnType<typeof leads.captureStatus>>,
78
- { ok: true }
79
- >["body"]["data"];
80
-
81
90
  type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
82
91
  type ContactFormSection = Extract<Section, { type: "contact-form" }>;
83
92
 
@@ -113,36 +122,44 @@ function builtInFields(text: LeadCaptureFormText, locale: string): ContactFormFi
113
122
  });
114
123
  }
115
124
 
116
- /** The API's capture status, or `null` for any failed read (see header). */
117
- async function readCaptureStatus(): Promise<CaptureStatus | null> {
118
- try {
119
- const res = await leads.captureStatus();
120
- return res.ok && res.body.ok === true ? res.body.data : null;
121
- } catch {
122
- return null;
123
- }
124
- }
125
-
126
125
  /**
127
- * The served Contact form magnet's config, or `null` for any failed read: a
128
- * non-2xx (absent, draft, a build with no flow route) or a throw. The
129
- * property access sits inside the `try`, so a client with no `flows` is a
130
- * failed read too.
126
+ * The page's view of the Contact form (#8847 D7): the served magnet's when it
127
+ * maps, else the built-in config's. `served` says which.
131
128
  */
132
- async function readContactMagnet(): Promise<FlowConfig | null> {
133
- try {
134
- const res = await flows.get(CONTACT_FORM_FLOW_SLUG);
135
- return res.ok ? res.body : null;
136
- } catch {
137
- return null;
138
- }
129
+ async function contactView(
130
+ locale: string,
131
+ fallbackHeadline: string,
132
+ ): Promise<{ view: InPageView; served: boolean }> {
133
+ const magnet = await readServedMagnet(CONTACT_FORM_FLOW_SLUG);
134
+ const served = magnet ? inPageView(magnet, locale, "contact", fallbackHeadline) : null;
135
+ if (served) return { view: served, served: true };
136
+ // The built-in config always maps: its first step has name and email.
137
+ return {
138
+ view: inPageView(contactFormFlowConfig, locale, "contact", fallbackHeadline)!,
139
+ served: false,
140
+ };
139
141
  }
140
142
 
141
143
  export async function generateMetadata({
142
144
  params,
143
- }: Pick<Props, "params">): Promise<Metadata> {
145
+ getMdxSource,
146
+ getPageSections,
147
+ }: Props): Promise<Metadata> {
144
148
  const { locale } = await params;
145
- return renderPageMetadata({ type: "ContactPage", slug: "contact", locale });
149
+ const t = await getTranslations({ locale, namespace: "contact" });
150
+ const { view } = await contactView(locale, t("successMessage"));
151
+ // #8847 D7 — the `<title>` is the magnet's step title, like the H1.
152
+ const framed = framedSources({
153
+ getMdxSource: getMdxSource ?? loadMdxSource,
154
+ getPageSections: getPageSections ?? loadPageSections,
155
+ view,
156
+ });
157
+ return renderPageMetadata({
158
+ type: "ContactPage",
159
+ slug: "contact",
160
+ locale,
161
+ getMdxSource: framed.getMdxSource,
162
+ });
146
163
  }
147
164
 
148
165
  export default async function Page({
@@ -151,17 +168,22 @@ export default async function Page({
151
168
  getPageSections,
152
169
  }: Props): Promise<ReactElement> {
153
170
  const { locale } = await params;
154
- const [status, t, magnet] = await Promise.all([
171
+ const [status, t] = await Promise.all([
155
172
  readCaptureStatus(),
156
173
  getTranslations({ locale, namespace: "contact" }),
157
- readContactMagnet(),
158
174
  ]);
159
175
  const accepting = status?.contact ?? true;
160
176
  const contactEmail = readSiteContactEmail();
161
177
  const text = contactFormText(t);
178
+ const { view, served } = await contactView(locale, text.success);
179
+ const framed = framedSources({
180
+ getMdxSource: getMdxSource ?? loadMdxSource,
181
+ getPageSections: getPageSections ?? loadPageSections,
182
+ view,
183
+ });
162
184
 
163
- const source = await (getMdxSource ?? loadMdxSource)("ContactPage", "contact", locale);
164
- const sections = source ? await (getPageSections ?? loadPageSections)(source.frontmatter) : [];
185
+ const source = await framed.getMdxSource("ContactPage", "contact", locale);
186
+ const sections = source ? await framed.getPageSections(source.frontmatter) : [];
165
187
  const formSection = sections.find(
166
188
  (s): s is ContactFormSection => s.type === "contact-form",
167
189
  );
@@ -173,19 +195,23 @@ export default async function Page({
173
195
  (lead?.type === "hero" && lead.data.variant === "split" ? lead.id : undefined);
174
196
 
175
197
  // #8661 D6 — the magnet's fields, else the section's, else the built-in form.
176
- const served = magnet ? contactFormFromConfig(magnet, locale) : null;
177
- const declared = formSection && formSection.data.fields.length > 0 ? formSection : undefined;
178
- const fields: ContactFormField[] =
179
- served?.fields ?? (declared ? sectionFields(declared) : builtInFields(text, locale));
180
- // A magnet's own submit label wins; the section's copy fills what it leaves.
181
- const submitLabel = served?.submitLabel ?? formSection?.data.submitLabel;
182
- const successMessage = formSection?.data.successMessage;
198
+ const form = view.form as ContactFormView;
199
+ const declared =
200
+ !served && formSection && formSection.data.fields.length > 0 ? formSection : undefined;
201
+ const fields: ContactFormField[] = served
202
+ ? form.fields
203
+ : declared
204
+ ? sectionFields(declared)
205
+ : builtInFields(text, locale);
206
+ // #8847 D7 — the section's submit label and success message apply only to
207
+ // its own fields; otherwise the magnet's (or the built-in config's) ending.
208
+ const submitLabel = served ? form.submitLabel : declared?.data.submitLabel;
183
209
  const column = formSection?.data.variant === "column";
184
210
 
185
- const surface = accepting ? (
211
+ const leadForm = accepting ? (
186
212
  <LeadCaptureForm
187
213
  action={captureLeadAction}
188
- text={served?.consentStatement ? { ...text, consentStatement: served.consentStatement } : text}
214
+ text={served && form.consentStatement ? { ...text, consentStatement: form.consentStatement } : text}
189
215
  termsHref={localeHref(locale, "/terms")}
190
216
  policies={{
191
217
  terms: localeHref(locale, "/terms"),
@@ -195,7 +221,7 @@ export default async function Page({
195
221
  fallbackEmail={fallbackEmail}
196
222
  fields={fields}
197
223
  submitLabel={submitLabel}
198
- successMessage={successMessage}
224
+ {...(declared ? { successMessage: declared.data.successMessage } : { ending: view.ending })}
199
225
  {...(served && { source: flowLeadSource(CONTACT_FORM_FLOW_SLUG) })}
200
226
  {...(column && { look: "column" as const, consentGate: "on-submit" as const })}
201
227
  />
@@ -209,14 +235,27 @@ export default async function Page({
209
235
  }}
210
236
  />
211
237
  );
238
+ // #8847 D7 — one muted line under the form; absent with no address.
239
+ const surface = (
240
+ <>
241
+ {leadForm}
242
+ {contactEmail !== undefined && (
243
+ <div className="mt-block-tight text-center" data-testid="contact-prefer-email">
244
+ <Text tone="muted" variant="body-sm">
245
+ {t("preferEmail")} <Link href={`mailto:${contactEmail}`}>{contactEmail}</Link>
246
+ </Text>
247
+ </div>
248
+ )}
249
+ </>
250
+ );
212
251
 
213
252
  const rendered = await renderPage({
214
253
  type: "ContactPage",
215
254
  slug: "contact",
216
255
  locale,
217
256
  translationsNamespace: "contact",
218
- ...(getMdxSource !== undefined && { getMdxSource }),
219
- ...(getPageSections !== undefined && { getPageSections }),
257
+ getMdxSource: framed.getMdxSource,
258
+ getPageSections: framed.getPageSections,
220
259
  ...(slotted !== undefined && { sectionSlots: { [slotted]: surface } }),
221
260
  });
222
261
  return (