create-ailk 0.7.0 → 0.9.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 (310) hide show
  1. package/README.md +112 -10
  2. package/component-catalog.json +798 -49
  3. package/component-catalog.md +9 -6
  4. package/dist/add-module.d.ts +20 -0
  5. package/dist/add-module.js +43 -7
  6. package/dist/add-route.d.ts +56 -0
  7. package/dist/add-route.js +286 -0
  8. package/dist/apply-design-md.d.ts +7 -0
  9. package/dist/apply-design-md.js +50 -0
  10. package/dist/apply-design.d.ts +64 -0
  11. package/dist/apply-design.js +273 -0
  12. package/dist/claude-md.d.ts +111 -0
  13. package/dist/claude-md.js +334 -0
  14. package/dist/cli.js +114 -28
  15. package/dist/derive-architecture.js +41 -36
  16. package/dist/design-compile.d.ts +71 -0
  17. package/dist/design-compile.js +98 -0
  18. package/dist/design-package.d.ts +83 -0
  19. package/dist/design-package.js +468 -0
  20. package/dist/design-presets.d.ts +86 -0
  21. package/dist/design-presets.js +642 -0
  22. package/dist/design-scaffold.d.ts +76 -0
  23. package/dist/design-scaffold.js +220 -0
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +4 -1
  26. package/dist/legacy-nav-renderers.d.ts +45 -0
  27. package/dist/legacy-nav-renderers.js +545 -0
  28. package/dist/lib/auth-secret-file.d.ts +19 -0
  29. package/dist/lib/auth-secret-file.js +112 -0
  30. package/dist/lib/design-checks.d.ts +78 -0
  31. package/dist/lib/design-checks.js +174 -0
  32. package/dist/lib/extract-module-bundle.js +5 -1
  33. package/dist/lib/license-key-files.d.ts +9 -0
  34. package/dist/lib/license-key-files.js +2 -2
  35. package/dist/lib/module-license-gate.d.ts +89 -0
  36. package/dist/lib/module-license-gate.js +171 -12
  37. package/dist/lib/module-tier.d.ts +45 -16
  38. package/dist/lib/module-tier.js +76 -0
  39. package/dist/lib/pro-callout.js +10 -2
  40. package/dist/lib/render-verdict.d.ts +52 -0
  41. package/dist/lib/render-verdict.js +77 -0
  42. package/dist/lib/scaffold-render-sweep.d.ts +105 -0
  43. package/dist/lib/scaffold-render-sweep.js +236 -0
  44. package/dist/module-architecture.d.ts +19 -16
  45. package/dist/module-architecture.js +64 -21
  46. package/dist/parse-args.d.ts +88 -10
  47. package/dist/parse-args.js +272 -21
  48. package/dist/programmatic.d.ts +66 -12
  49. package/dist/programmatic.js +361 -34
  50. package/dist/site-chrome.d.ts +19 -9
  51. package/dist/site-chrome.js +23 -13
  52. package/dist/site-manifest.d.ts +25 -0
  53. package/dist/site-manifest.js +254 -0
  54. package/dist/site-roster.d.ts +41 -0
  55. package/dist/site-roster.js +149 -11
  56. package/dist/starter-grid.d.ts +232 -0
  57. package/dist/starter-grid.js +545 -0
  58. package/dist/surfaces.d.ts +41 -9
  59. package/dist/surfaces.js +268 -92
  60. package/dist/template-manifest.d.ts +65 -29
  61. package/dist/template-manifest.js +161 -382
  62. package/dist/template-names.d.ts +14 -12
  63. package/dist/template-names.js +13 -17
  64. package/package.json +5 -4
  65. package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
  66. package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
  67. package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
  68. package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
  69. package/site-templates/_base/content/en/GlossaryList/glossary.mdx +0 -1
  70. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +0 -1
  71. package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
  72. package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
  73. package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
  74. package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
  75. package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
  76. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +15 -0
  77. package/site-templates/_base/content/en/UseCase/persona-1.mdx +70 -0
  78. package/site-templates/_base/content/en/UseCase/persona-2.mdx +70 -0
  79. package/site-templates/_base/content/en/UseCase/persona-3.mdx +70 -0
  80. package/site-templates/{minimalism-site/TEMPLATE-MANIFEST.json → aliases/minimalism-site.TEMPLATE-MANIFEST.json} +72 -40
  81. package/site-templates/designs/minimalism/DESIGN.md +98 -6
  82. package/site-templates/designs/minimalism/sample/README.md +48 -0
  83. package/site-templates/designs/standard/DESIGN.md +103 -6
  84. package/site-templates/marketing-site/DESIGN.md +103 -6
  85. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +144 -47
  86. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +82 -41
  87. package/site-templates/marketing-site-free/DESIGN.md +103 -6
  88. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +150 -48
  89. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +114 -67
  90. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  91. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
  92. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
  93. package/site-templates/saas-product/DESIGN.md +103 -6
  94. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
  95. package/site-templates/{simple-service → shapes/local-business-service}/TEMPLATE-MANIFEST.json +210 -81
  96. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +356 -0
  97. package/site-templates/shapes/local-business-service/ailk-template.json +10 -0
  98. package/site-templates/{simple-service → shapes/local-business-service}/content/en/AboutPage/about.mdx +4 -4
  99. package/site-templates/shapes/local-business-service/content/en/HomePage/home.mdx +70 -0
  100. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  101. package/site-templates/shapes/local-business-service/content/en/PortfolioList/portfolio.mdx +171 -0
  102. package/site-templates/{minimalism-site → shapes/local-business-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +7 -8
  103. package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +39 -0
  104. package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +39 -0
  105. package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +39 -0
  106. package/site-templates/shapes/local-business-service/content/en/ServicesList/services.mdx +61 -0
  107. package/site-templates/shapes/local-business-service/provenance.json +4 -0
  108. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +471 -0
  109. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +354 -0
  110. package/site-templates/shapes/organization-service/ailk-template.json +10 -0
  111. package/site-templates/shapes/organization-service/content/en/AboutPage/about.mdx +38 -0
  112. package/site-templates/shapes/organization-service/content/en/HomePage/home.mdx +85 -0
  113. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  114. package/site-templates/shapes/organization-service/content/en/PortfolioList/portfolio.mdx +171 -0
  115. package/site-templates/shapes/organization-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  116. package/site-templates/{minimalism-site → shapes/organization-service}/content/en/ServicesList/services.mdx +3 -4
  117. package/site-templates/shapes/organization-service/provenance.json +4 -0
  118. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +478 -0
  119. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +353 -0
  120. package/site-templates/shapes/person-service/ailk-template.json +10 -0
  121. package/site-templates/shapes/person-service/content/_site.mdx +85 -0
  122. package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
  123. package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
  124. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  125. package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +171 -0
  126. package/site-templates/{simple-service → shapes/person-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +10 -11
  127. package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
  128. package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
  129. package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
  130. package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
  131. package/site-templates/shapes/person-service/provenance.json +4 -0
  132. package/templates/.claude/rules/architecture.md +7 -0
  133. package/templates/.claude/skills/README.md +5 -3
  134. package/templates/.claude/skills/add-auth/references/env-vars.md +22 -15
  135. package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +3 -2
  136. package/templates/.claude/skills/add-page/SKILL.md +241 -238
  137. package/templates/.claude/skills/add-page/references/page-types.md +85 -72
  138. package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
  139. package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
  140. package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
  141. package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
  142. package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
  143. package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
  144. package/templates/.claude/skills/check-site/SKILL.md +23 -0
  145. package/templates/.claude/skills/fill-page/SKILL.md +26 -7
  146. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
  147. package/templates/.claude/skills/setup-database/SKILL.md +156 -0
  148. package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
  149. package/templates/.claude/skills/setup-deploy/SKILL.md +64 -32
  150. package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +44 -36
  151. package/templates/.claude/skills/setup-deploy/references/runbook-template.md +58 -102
  152. package/templates/.claude/skills/update-page/SKILL.md +117 -0
  153. package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
  154. package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
  155. package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
  156. package/templates/.github/workflows/ci.yml +4 -0
  157. package/templates/.prettierignore +1 -0
  158. package/templates/CLAUDE.md +146 -103
  159. package/templates/DESIGN.md +103 -6
  160. package/templates/README.md +63 -43
  161. package/templates/apps/api/.env.example +2 -1
  162. package/templates/apps/api/CLAUDE.md +3 -2
  163. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
  164. package/templates/apps/api/src/__tests__/server.test.ts +116 -0
  165. package/templates/apps/api/src/config/__tests__/index.test.ts +115 -14
  166. package/templates/apps/api/src/config/index.ts +35 -16
  167. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
  168. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
  169. package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
  170. package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
  171. package/templates/apps/api/src/lib/stripe.ts +9 -0
  172. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
  173. package/templates/apps/api/src/middleware/auth.ts +54 -0
  174. package/templates/apps/api/src/openapi/spec.ts +63 -12
  175. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
  176. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
  177. package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
  178. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
  179. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
  180. package/templates/apps/api/src/routes/leads/index.ts +46 -1
  181. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
  182. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
  183. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
  184. package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
  185. package/templates/apps/api/src/server.ts +35 -0
  186. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
  187. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
  188. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
  189. package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
  190. package/templates/apps/api/src/services/lead-routing.ts +140 -30
  191. package/templates/apps/api/src/services/leads-inbox.ts +48 -11
  192. package/templates/apps/api/src/services/revalidation.ts +60 -15
  193. package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
  194. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
  195. package/templates/apps/mcp/src/tools/create_project.ts +21 -6
  196. package/templates/apps/mcp/src/tools/purchase.ts +4 -2
  197. package/templates/apps/web/.env.example +13 -0
  198. package/templates/apps/web/CLAUDE.md +22 -28
  199. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
  200. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
  201. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
  202. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
  203. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
  204. package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
  205. package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
  206. package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
  207. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +14 -6
  208. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +10 -9
  209. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
  210. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
  211. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +227 -84
  212. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
  213. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
  214. package/templates/apps/web/app/[locale]/contact/page.tsx +105 -15
  215. package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
  216. package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
  217. package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
  218. package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +21 -5
  219. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
  220. package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
  221. package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
  222. package/templates/apps/web/app/[locale]/layout.tsx +65 -17
  223. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
  224. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
  225. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
  226. package/templates/apps/web/app/[locale]/newsletter/page.tsx +106 -14
  227. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
  228. package/templates/apps/web/app/[locale]/{book-a-call → portfolio}/page.tsx +12 -13
  229. package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
  230. package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
  231. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
  232. package/templates/apps/web/app/api/revalidate/route.ts +78 -36
  233. package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
  234. package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
  235. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
  236. package/templates/apps/web/lib/locale-guard.ts +18 -0
  237. package/templates/apps/web/lib/meet-placeholder.ts +5 -0
  238. package/templates/apps/web/lib/nav-config.ts +50 -37
  239. package/templates/apps/web/lib/site-brand.tsx +13 -9
  240. package/templates/apps/web/lib/site-identity.ts +14 -0
  241. package/templates/apps/web/lib/site-logo.tsx +36 -0
  242. package/templates/apps/web/lib/site-theme.ts +46 -26
  243. package/templates/apps/web/messages/en.json +45 -5
  244. package/templates/apps/web/middleware.ts +28 -4
  245. package/templates/content/_site.mdx +4 -2
  246. package/templates/content/en/BlogList/blog.mdx +37 -16
  247. package/templates/content/en/BookCall/book-a-call.mdx +80 -0
  248. package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
  249. package/templates/content/en/BookCall/onsite-service.mdx +56 -0
  250. package/templates/content/en/BookCall/start-project.mdx +56 -0
  251. package/templates/content/en/ContactPage/contact.mdx +43 -34
  252. package/templates/content/en/HomePage/home.mdx +10 -22
  253. package/templates/content/en/LandingPage/meet.mdx +59 -0
  254. package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
  255. package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
  256. package/templates/database/CHANGELOG.md +14 -0
  257. package/templates/database/package.json +1 -1
  258. package/templates/package.json +2 -1
  259. package/templates/tools/check-claude-md.ts +128 -0
  260. package/versions.json +33 -33
  261. package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -53
  262. package/site-templates/_base/content/en/UseCase/first-use-case.mdx +0 -23
  263. package/site-templates/_base/content/en/UseCase/second-use-case.mdx +0 -23
  264. package/site-templates/_base/content/en/UseCase/third-use-case.mdx +0 -23
  265. package/site-templates/minimalism-site/DESIGN.md +0 -162
  266. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -470
  267. package/site-templates/minimalism-site/ailk-template.json +0 -23
  268. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
  269. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -53
  270. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
  271. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
  272. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
  273. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
  274. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
  275. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
  276. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
  277. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
  278. package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
  279. package/site-templates/minimalism-site/provenance.json +0 -4
  280. package/site-templates/simple-service/DESIGN.md +0 -162
  281. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -333
  282. package/site-templates/simple-service/ailk-template.json +0 -11
  283. package/site-templates/simple-service/content/en/BookCall/book-call.mdx +0 -53
  284. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -32
  285. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
  286. package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
  287. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
  288. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
  289. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
  290. package/site-templates/simple-service/provenance.json +0 -4
  291. package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
  292. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -211
  293. package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +0 -96
  294. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
  295. package/templates/content/en/SoftwareProduct/product.mdx +0 -175
  296. /package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +0 -0
  297. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
  298. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
  299. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
  300. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
  301. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
  302. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
  303. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
  304. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
  305. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
  306. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
  307. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
  308. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/first-engagement.mdx +0 -0
  309. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/second-engagement.mdx +0 -0
  310. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/third-engagement.mdx +0 -0
@@ -0,0 +1,60 @@
1
+ // The Free /meet/<slug> placeholder detail route (#6897 D2). BookCall
2
+ // content served at /meet/<detail> by Next.js static-segment precedence:
3
+ // wherever apps/web/app/[locale]/meet/ exists (the scheduling module), that
4
+ // directory resolves every /meet/* URL first and this route never runs. On a
5
+ // keyless scaffold, this route serves the four Free placeholder detail pages.
6
+ //
7
+ // `slug` must equal MEET_PLACEHOLDER_HUB ("meet") — any other value 404s.
8
+ // This route deliberately serves ONLY the one meet hub, not a generic
9
+ // two-segment content route: BookCall's slug pattern forbids "/", so a
10
+ // generic join would also be reachable at a one-segment URL through the
11
+ // `[slug]` route — a duplicate-content defect. A second hub can generalize
12
+ // this route when one exists.
13
+ //
14
+ // Unknown detail slug → notFound() (renderPage's own detail-route branch: no
15
+ // translationsNamespace is passed, so a null source 404s rather than
16
+ // rendering an empty-state placeholder — the same contract `[slug]/page.tsx`
17
+ // documents).
18
+ //
19
+ // Deliberately NO generateStaticParams, for the same reason as `[slug]/page.tsx`:
20
+ // the locale layout reads cookies()/headers(), so this segment always renders
21
+ // on request.
22
+ import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
23
+ import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
24
+ import type { Metadata } from "next";
25
+ import { notFound } from "next/navigation";
26
+ import type { ReactElement } from "react";
27
+
28
+ import { MEET_PLACEHOLDER_HUB } from "../../../../lib/meet-placeholder";
29
+
30
+ interface Props {
31
+ params: Promise<{ locale: string; slug: string; detail: string }>;
32
+ /** Test-only DI override for getMdxSource. Production callers leave undefined. */
33
+ getMdxSource?: typeof defaultGetMdxSource;
34
+ }
35
+
36
+ export async function generateMetadata({
37
+ params,
38
+ }: Pick<Props, "params">): Promise<Metadata> {
39
+ const { locale, slug, detail } = await params;
40
+ // Mirrors renderPageMetadata's own missing-frontmatter → {} contract: a
41
+ // hub mismatch is the same "nothing to title" case, not a thrown 404 —
42
+ // generateMetadata cannot itself change the response status.
43
+ if (slug !== MEET_PLACEHOLDER_HUB) return {};
44
+ return renderPageMetadata({ type: "BookCall", slug: detail, locale });
45
+ }
46
+
47
+ export default async function Page({
48
+ params,
49
+ getMdxSource,
50
+ }: Props): Promise<ReactElement> {
51
+ const { locale, slug, detail } = await params;
52
+ if (slug !== MEET_PLACEHOLDER_HUB) notFound();
53
+ return renderPage({
54
+ type: "BookCall",
55
+ slug: detail,
56
+ locale,
57
+ populatedDescriptionTestId: `${detail}-description`,
58
+ ...(getMdxSource !== undefined && { getMdxSource }),
59
+ });
60
+ }
@@ -21,7 +21,7 @@
21
21
  // `ItemList` — the correct page type, not a LandingPage wearing a `schema:`
22
22
  // override the structured-data gate rejects) served here, and still yields
23
23
  // to the module's directory by the same precedence. Every other list type
24
- // has its own route directory (`/features` → ProductsList, `/use-cases` →
24
+ // has its own route directory (`/features` → ProductsList, `/for` →
25
25
  // UseCasesList) and never reaches this route.
26
26
  //
27
27
  // Unknown slug → `notFound()` (renderPage's own detail-route branch: no
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Tests for /alternatives (#6878 D9) — the ComparisonsList hub.
3
+ *
4
+ * `renderPage`/`renderPageMetadata`/`getMdxSlugs` are mocked at the
5
+ * `@working-theory/page-renderer` boundary so this suite can assert on the
6
+ * exact input `renderPage` receives — in particular the injected
7
+ * `getPageSections` override that appends the generated spoke list (AC-7:
8
+ * "the hub links each /vs spoke"). `getMdxSource` and `getPageSections`
9
+ * (the hub's own declared sections) are supplied per-call through the
10
+ * route's DI props, as every other page in this app does.
11
+ */
12
+ // The mock return value is never rendered — every assertion below inspects
13
+ // `mockRenderPage.mock.calls` directly — so it carries no `ReactElement`
14
+ // cast; `renderPage`'s real return type is `Promise<ReactElement>` in
15
+ // production, unaffected by this mock's loose type here.
16
+ const mockRenderPage = jest.fn(async (..._args: unknown[]) => "<rendered/>");
17
+ const mockRenderPageMetadata = jest.fn((..._args: unknown[]) => undefined);
18
+ const mockGetMdxSlugs = jest.fn((..._args: unknown[]) => Promise.resolve<string[]>([]));
19
+ const mockNotFound = jest.fn(() => {
20
+ throw new Error("NEXT_NOT_FOUND");
21
+ });
22
+
23
+ jest.mock("next/navigation", () => ({
24
+ __esModule: true,
25
+ notFound: () => mockNotFound(),
26
+ }));
27
+
28
+ jest.mock("@working-theory/page-renderer", () => ({
29
+ __esModule: true,
30
+ getMdxSlugs: (...args: unknown[]) => mockGetMdxSlugs(...args),
31
+ renderPage: (...args: unknown[]) => mockRenderPage(...args),
32
+ renderPageMetadata: (...args: unknown[]) => mockRenderPageMetadata(...args),
33
+ // Never reached — every call in this suite passes both via DI props.
34
+ getMdxSource: jest.fn(),
35
+ getPageSections: jest.fn(),
36
+ }));
37
+
38
+ import AlternativesPage, { generateMetadata } from "../page";
39
+
40
+ const HUB_SOURCE = {
41
+ frontmatter: {
42
+ type: "ComparisonsList",
43
+ slug: "alternatives",
44
+ title: "Alternatives",
45
+ description: "How we compare.",
46
+ },
47
+ body: "",
48
+ };
49
+
50
+ beforeEach(() => jest.clearAllMocks());
51
+
52
+ describe("AlternativesPage — hub seed absent", () => {
53
+ it("calls notFound() and never reaches renderPage", async () => {
54
+ const getMdxSource = jest.fn().mockResolvedValue(null);
55
+
56
+ await expect(
57
+ AlternativesPage({
58
+ params: Promise.resolve({ locale: "en" }),
59
+ getMdxSource,
60
+ getPageSections: jest.fn(),
61
+ }),
62
+ ).rejects.toThrow("NEXT_NOT_FOUND");
63
+
64
+ expect(mockNotFound).toHaveBeenCalledTimes(1);
65
+ expect(mockRenderPage).not.toHaveBeenCalled();
66
+ });
67
+ });
68
+
69
+ describe("AlternativesPage — metadata", () => {
70
+ it("resolves ComparisonsList slug alternatives", async () => {
71
+ await generateMetadata({ params: Promise.resolve({ locale: "en" }) });
72
+ expect(mockRenderPageMetadata).toHaveBeenCalledWith({
73
+ type: "ComparisonsList",
74
+ slug: "alternatives",
75
+ locale: "en",
76
+ });
77
+ });
78
+ });
79
+
80
+ describe("AlternativesPage — generated spoke list (D9)", () => {
81
+ it("appends one items section entry per ComparisonPage spoke, each linking /vs/<slug>", async () => {
82
+ mockGetMdxSlugs.mockResolvedValue(["acme", "globex"]);
83
+ const spokeSources: Record<string, unknown> = {
84
+ acme: {
85
+ frontmatter: { title: "vs. Acme", description: "How we compare to Acme." },
86
+ body: "",
87
+ },
88
+ globex: {
89
+ frontmatter: { title: "vs. Globex", description: "How we compare to Globex." },
90
+ body: "",
91
+ },
92
+ };
93
+ const getMdxSource = jest
94
+ .fn()
95
+ .mockImplementation((type: string, slug: string) => {
96
+ if (type === "ComparisonsList") return Promise.resolve(HUB_SOURCE);
97
+ return Promise.resolve(spokeSources[slug] ?? null);
98
+ });
99
+ const getPageSections = jest
100
+ .fn()
101
+ .mockResolvedValue([{ id: "hero", type: "hero", data: {} }]);
102
+
103
+ await AlternativesPage({
104
+ params: Promise.resolve({ locale: "en" }),
105
+ getMdxSource,
106
+ getPageSections,
107
+ });
108
+
109
+ expect(mockRenderPage).toHaveBeenCalledTimes(1);
110
+ const passedInput = mockRenderPage.mock.calls[0]?.[0] as {
111
+ type: string;
112
+ slug: string;
113
+ getPageSections: (frontmatter: unknown) => Promise<unknown[]>;
114
+ };
115
+ expect(passedInput.type).toBe("ComparisonsList");
116
+ expect(passedInput.slug).toBe("alternatives");
117
+
118
+ const sections = await passedInput.getPageSections({});
119
+ expect(sections).toEqual([
120
+ { id: "hero", type: "hero", data: {} },
121
+ {
122
+ type: "items",
123
+ id: "comparison-spokes",
124
+ data: {
125
+ items: [
126
+ {
127
+ title: "vs. Acme",
128
+ description: "How we compare to Acme.",
129
+ link: { label: "Read the comparison", href: "/vs/acme" },
130
+ },
131
+ {
132
+ title: "vs. Globex",
133
+ description: "How we compare to Globex.",
134
+ link: { label: "Read the comparison", href: "/vs/globex" },
135
+ },
136
+ ],
137
+ },
138
+ },
139
+ ]);
140
+ });
141
+
142
+ it("omits the generated section entirely with zero spokes (the hub's own sections render alone)", async () => {
143
+ mockGetMdxSlugs.mockResolvedValue([]);
144
+ const getMdxSource = jest.fn().mockResolvedValue(HUB_SOURCE);
145
+ const getPageSections = jest
146
+ .fn()
147
+ .mockResolvedValue([{ id: "hero", type: "hero", data: {} }]);
148
+
149
+ await AlternativesPage({
150
+ params: Promise.resolve({ locale: "en" }),
151
+ getMdxSource,
152
+ getPageSections,
153
+ });
154
+
155
+ const passedInput = mockRenderPage.mock.calls[0]?.[0] as {
156
+ getPageSections: (frontmatter: unknown) => Promise<unknown[]>;
157
+ };
158
+ const sections = await passedInput.getPageSections({});
159
+ expect(sections).toEqual([{ id: "hero", type: "hero", data: {} }]);
160
+ });
161
+ });
@@ -0,0 +1,104 @@
1
+ // ComparisonsList hub (#6878 D9) — the /alternatives page. Reads
2
+ // ComparisonsList MDX via the content adapter, injects a generated `items`
3
+ // section listing every ComparisonPage spoke (so the visible card grid and
4
+ // the CollectionPage's ItemList JSON-LD — built from the same sections —
5
+ // always agree, D9), and emits via the dispatcher.
6
+ //
7
+ // notFound() when the hub seed is absent, so no empty hub is ever served
8
+ // (unlike the /for hub's empty-state branch): an add-page run always writes
9
+ // the seed alongside the first spoke (D11), so a fresh scaffold — which
10
+ // excludes this gated surface entirely — never reaches this branch in
11
+ // production, only a mis-authored site would.
12
+ //
13
+ // Gated off by default (surfaces.ts `alternatives` surface): no roster names
14
+ // this route, so a fresh scaffold excludes it.
15
+ import type {
16
+ getMdxSource as defaultGetMdxSource,
17
+ getPageSections as defaultGetPageSections,
18
+ } from "@working-theory/page-renderer";
19
+ import {
20
+ getMdxSlugs,
21
+ getMdxSource as loadMdxSource,
22
+ getPageSections as loadPageSections,
23
+ renderPage,
24
+ renderPageMetadata,
25
+ } from "@working-theory/page-renderer";
26
+ import type { Metadata } from "next";
27
+ import { notFound } from "next/navigation";
28
+ import type { ReactElement } from "react";
29
+
30
+ interface Props {
31
+ params: Promise<{ locale: string }>;
32
+ getMdxSource?: typeof defaultGetMdxSource;
33
+ getPageSections?: typeof defaultGetPageSections;
34
+ }
35
+
36
+ type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
37
+
38
+ export async function generateMetadata({
39
+ params,
40
+ }: Pick<Props, "params">): Promise<Metadata> {
41
+ const { locale } = await params;
42
+ return renderPageMetadata({
43
+ type: "ComparisonsList",
44
+ slug: "alternatives",
45
+ locale,
46
+ });
47
+ }
48
+
49
+ export default async function Page({
50
+ params,
51
+ getMdxSource,
52
+ getPageSections,
53
+ }: Props): Promise<ReactElement> {
54
+ const { locale } = await params;
55
+ const load = getMdxSource ?? loadMdxSource;
56
+ const loadSections = getPageSections ?? loadPageSections;
57
+
58
+ const source = await load("ComparisonsList", "alternatives", locale);
59
+ if (source === null) {
60
+ notFound();
61
+ }
62
+
63
+ /**
64
+ * Append one generated `items` section listing every ComparisonPage spoke
65
+ * — D9's "the visible list and the CollectionPage's ItemList always
66
+ * agree" property, since `renderPage` builds JSON-LD from these same
67
+ * sections. Absent when there are zero spokes: the hub still renders its
68
+ * own authored sections and answers 200 on its seed alone.
69
+ */
70
+ async function withSpokes(frontmatter: unknown): Promise<Section[]> {
71
+ const declared = await loadSections(frontmatter);
72
+ const slugs = await getMdxSlugs("ComparisonPage", locale);
73
+ if (slugs.length === 0) return declared;
74
+
75
+ const items = await Promise.all(
76
+ slugs.map(async (slug) => {
77
+ const spoke = await load("ComparisonPage", slug, locale);
78
+ const fm = (spoke?.frontmatter ?? {}) as {
79
+ title?: string;
80
+ description?: string;
81
+ };
82
+ return {
83
+ title: fm.title ?? slug,
84
+ description: fm.description ?? "",
85
+ link: { label: "Read the comparison", href: `/vs/${slug}` },
86
+ };
87
+ }),
88
+ );
89
+
90
+ return [
91
+ ...declared,
92
+ { type: "items" as const, id: "comparison-spokes", data: { items } },
93
+ ];
94
+ }
95
+
96
+ return renderPage({
97
+ type: "ComparisonsList",
98
+ slug: "alternatives",
99
+ locale,
100
+ translationsNamespace: "alternatives",
101
+ getPageSections: withSpokes,
102
+ ...(getMdxSource !== undefined && { getMdxSource }),
103
+ });
104
+ }
@@ -8,8 +8,8 @@
8
8
  * conversation logic, only:
9
9
  *
10
10
  * 1. Submission — `onComplete` hands the finished answers to the SAME
11
- * `captureLeadAction` (`./contact.actions.ts`) the plain `<ContactForm/>`
12
- * already uses, built as a `FormData` (imperatively, not a real
11
+ * `captureLeadAction` (`./contact.actions.ts`) the shared
12
+ * `LeadCaptureForm` on `/contact` already uses, built as a `FormData` (imperatively, not a real
13
13
  * `<form>` submit — React 19 Server Actions are plain async functions).
14
14
  * That action posts through the EXISTING `POST /v1/leads/submit` route
15
15
  * (`apps/api/src/routes/leads`, consumed via `@working-theory/api-client`,
@@ -21,7 +21,8 @@
21
21
  * second web surface, not a new one. `contact.actions.ts`'s own
22
22
  * doc-comment covers the additive `source` FormData field this file
23
23
  * sets to distinguish intake-originated leads.
24
- * 2. Skip fallback — `onSkip` swaps to the EXISTING `<ContactForm/>`,
24
+ * 2. Skip fallback — `onSkip` swaps to the shared `LeadCaptureForm` block
25
+ * (#6730 — the one contact form), bound to the same action,
25
26
  * carrying over whatever answers were already captured as its
26
27
  * `initialValues` prefill. A failed auto-submit on `onComplete` falls
27
28
  * back the same way, so a lead is never silently lost.
@@ -43,14 +44,16 @@ import {
43
44
  ConversationalIntake,
44
45
  type ConversationalIntakeProps,
45
46
  InlineMessage,
47
+ LeadCaptureForm,
48
+ localeHref,
46
49
  THINKING_DELAY_MAX_MS,
47
50
  THINKING_DELAY_MIN_MS,
48
51
  } from "@working-theory/ui";
49
52
  import { useLocale, useTranslations } from "next-intl";
50
53
  import { useCallback, useMemo, useRef, useState } from "react";
51
54
 
55
+ import { contactFormText } from "./contact-form-text";
52
56
  import { captureLeadAction } from "./contact.actions";
53
- import { ContactForm, type ContactFormInitialValues } from "./ContactForm";
54
57
  import { recordIntakeTurn } from "./intake-analytics";
55
58
  import { buildContactIntakeScript } from "./intake-script";
56
59
 
@@ -120,7 +123,7 @@ export function ConversationalIntakeWrapper({
120
123
  );
121
124
 
122
125
  const [surface, setSurface] = useState<Surface>("intake");
123
- const [initialValues, setInitialValues] = useState<ContactFormInitialValues>(
126
+ const [initialValues, setInitialValues] = useState<Record<string, string>>(
124
127
  {},
125
128
  );
126
129
  const [submitError, setSubmitError] = useState<string | undefined>(
@@ -193,7 +196,12 @@ export function ConversationalIntakeWrapper({
193
196
  {submitError}
194
197
  </InlineMessage>
195
198
  ) : null}
196
- <ContactForm initialValues={initialValues} />
199
+ <LeadCaptureForm
200
+ action={captureLeadAction}
201
+ text={contactFormText(t)}
202
+ termsHref={localeHref(locale, "/terms")}
203
+ initialValues={initialValues}
204
+ />
197
205
  </Container>
198
206
  );
199
207
  }
@@ -6,8 +6,8 @@
6
6
  * the EXISTING lead-capture endpoint. Only the outermost network
7
7
  * boundary is mocked (`@working-theory/api-client`'s `leads.submit`) —
8
8
  * the REAL `ConversationalIntake` block (#4543) and its engine (#4532),
9
- * the REAL `captureLeadAction` (the SAME action `<ContactForm/>` already
10
- * uses), and the REAL `submitLeadInputSchema` validation all run, proving
9
+ * the REAL `captureLeadAction` (the SAME action the shared
10
+ * `LeadCaptureForm` on `/contact` uses), and the REAL `submitLeadInputSchema` validation all run, proving
11
11
  * the whole chain end to end. No file under apps/api is imported, mocked,
12
12
  * or touched by this test or by the wiring it exercises.
13
13
  * AC2 — Skip renders the existing form, carrying over answers already
@@ -36,13 +36,14 @@ jest.mock("@working-theory/analytics/server", () => ({
36
36
  }));
37
37
 
38
38
  // ConversationalIntakeWrapper's own responsibility under test here is the
39
- // submission/skip/analytics wiring around the block — not ContactForm's own
40
- // rendering, which apps/web's existing tests already document as crashing
41
- // in jsdom (useFormState/useFormStatus; see page-renderer-migration.test.tsx).
42
- // Narrow internal-module mock, same precedent as that file's ContactForm stub.
43
- // eslint-disable-next-line no-restricted-syntax -- test-seam mock required; ContactForm uses useFormState/useFormStatus which crash in jsdom (see page-renderer-migration.test.tsx); narrowest possible scope
44
- jest.mock("../ContactForm", () => ({
45
- ContactForm: (props: { initialValues?: Record<string, string> }) =>
39
+ // submission/skip/analytics wiring around the block — not the shared
40
+ // LeadCaptureForm's own rendering, which its block suite covers
41
+ // (packages/ui/src/blocks/__tests__/LeadCaptureForm.test.tsx). The real
42
+ // `@working-theory/ui` runs (the ConversationalIntake block included); only
43
+ // the form block is stubbed, so the carried-over `initialValues` can be read.
44
+ jest.mock("@working-theory/ui", () => ({
45
+ ...jest.requireActual("@working-theory/ui"),
46
+ LeadCaptureForm: (props: { initialValues?: Record<string, string> }) =>
46
47
  require("react").createElement("div", {
47
48
  "data-testid": "contact-form-stub",
48
49
  "data-initial-values": JSON.stringify(props.initialValues ?? {}),
@@ -0,0 +1,32 @@
1
+ /**
2
+ * contactFormText (#6730 D8) — the `contact` namespace mapped onto the shared
3
+ * form block's copy, with `unavailableBody`'s `{email}` placeholder kept for
4
+ * the block to fill.
5
+ */
6
+ import messages from "../../../../messages/en.json";
7
+ import { contactFormText } from "../contact-form-text";
8
+
9
+ /** A translator over the English catalog that interpolates `{name}` values, as ICU does. */
10
+ function translator(key: string, values?: Record<string, string>): string {
11
+ const template = (messages.contact as Record<string, unknown>)[key];
12
+ if (typeof template !== "string") throw new Error(`missing contact.${key}`);
13
+ return template.replace(/\{(\w+)\}/g, (m, name: string) => values?.[name] ?? m);
14
+ }
15
+
16
+ describe("contactFormText", () => {
17
+ it("maps every block string from the English catalog, keeping {email} for the block", () => {
18
+ expect(contactFormText(translator)).toEqual({
19
+ nameLabel: "Name",
20
+ emailLabel: "Email",
21
+ messageLabel: "Message",
22
+ submit: "Send message",
23
+ sending: "Sending…",
24
+ success: messages.contact.successMessage,
25
+ genericError: messages.contact.genericError,
26
+ consentStatement: messages.contact.consentStatement,
27
+ unavailableHeading: messages.contact.unavailableHeading,
28
+ unavailableBody: "Email us at {email} and we'll reply.",
29
+ unavailableNoEmail: messages.contact.unavailableNoEmail,
30
+ });
31
+ });
32
+ });
@@ -179,3 +179,132 @@ describe("captureLeadAction — API error", () => {
179
179
  consoleErrorSpy.mockRestore();
180
180
  });
181
181
  });
182
+
183
+ // ─── #6730 D8 — honest outcomes and declared extra fields ────────────────────
184
+
185
+ describe("captureLeadAction — outcome mapping (#6730)", () => {
186
+ it("503 lead_capture_not_configured → unavailable", async () => {
187
+ mockSubmit.mockResolvedValueOnce({
188
+ ok: false,
189
+ status: 503,
190
+ body: { error: "lead_capture_not_configured", message: "Lead capture is not configured." },
191
+ });
192
+
193
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
194
+
195
+ expect(result).toEqual({ kind: "unavailable" });
196
+ });
197
+
198
+ it("503 delivery_failed → the generic error, never success", async () => {
199
+ mockSubmit.mockResolvedValueOnce({
200
+ ok: false,
201
+ status: 503,
202
+ body: { error: "delivery_failed", message: "The message could not be delivered." },
203
+ });
204
+
205
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
206
+
207
+ expect(result).toMatchObject({ kind: "error" });
208
+ expect(result).not.toMatchObject({ kind: "success" });
209
+ });
210
+
211
+ it.each([400, 429, 500, 503])("a non-2xx (%s) is never success", async (status) => {
212
+ mockSubmit.mockResolvedValueOnce({ ok: false, status, body: { error: "x" } });
213
+
214
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
215
+
216
+ expect(result?.kind).toBe("error");
217
+ });
218
+
219
+ it("201 → success", async () => {
220
+ mockSubmit.mockResolvedValueOnce({
221
+ ok: true,
222
+ body: { id: "email-1-abcdef12", persisted: "email" },
223
+ });
224
+
225
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
226
+
227
+ expect(result).toEqual({ kind: "success" });
228
+ });
229
+ });
230
+
231
+ describe("captureLeadAction — declared extra fields (#6730 D8a)", () => {
232
+ it("carries an extra string field (e.g. a select) into payload", async () => {
233
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
234
+
235
+ await captureLeadAction(INITIAL_STATE, makeFormData({ ...VALID_FIELDS, plan: "pro" }));
236
+
237
+ const body = mockSubmit.mock.calls[0][0] as { payload: Record<string, unknown> };
238
+ expect(body.payload.plan).toBe("pro");
239
+ });
240
+
241
+ it("never copies reserved or Next-internal fields into payload", async () => {
242
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
243
+
244
+ await captureLeadAction(
245
+ INITIAL_STATE,
246
+ makeFormData({
247
+ ...VALID_FIELDS,
248
+ consent: "true",
249
+ ailk_hp: "",
250
+ source: "conversational-intake",
251
+ $ACTION_ID_abc: "",
252
+ }),
253
+ );
254
+
255
+ const body = mockSubmit.mock.calls[0][0] as {
256
+ source: string;
257
+ payload: Record<string, unknown>;
258
+ };
259
+ expect(Object.keys(body.payload).sort()).toEqual(["message", "name"]);
260
+ expect(body.source).toBe("conversational-intake");
261
+ });
262
+
263
+ it("more than 20 extra keys → a form error, no API call", async () => {
264
+ const extras = Object.fromEntries(
265
+ Array.from({ length: 21 }, (_, i) => [`extra${i}`, "x"]),
266
+ );
267
+
268
+ const result = await captureLeadAction(
269
+ INITIAL_STATE,
270
+ makeFormData({ ...VALID_FIELDS, ...extras }),
271
+ );
272
+
273
+ expect(result).toMatchObject({ kind: "error" });
274
+ expect(mockSubmit).not.toHaveBeenCalled();
275
+ });
276
+
277
+ it("an extra value over 2000 characters → a form error, no API call", async () => {
278
+ const result = await captureLeadAction(
279
+ INITIAL_STATE,
280
+ makeFormData({ ...VALID_FIELDS, notes: "x".repeat(2001) }),
281
+ );
282
+
283
+ expect(result).toMatchObject({ kind: "error" });
284
+ expect(mockSubmit).not.toHaveBeenCalled();
285
+ });
286
+ });
287
+
288
+ describe("captureLeadAction — honeypot (#6730)", () => {
289
+ it("a filled honeypot → success, and nothing is sent", async () => {
290
+ const result = await captureLeadAction(
291
+ INITIAL_STATE,
292
+ makeFormData({ ...VALID_FIELDS, ailk_hp: "https://spam.example" }),
293
+ );
294
+
295
+ expect(result).toEqual({ kind: "success" });
296
+ expect(mockSubmit).not.toHaveBeenCalled();
297
+ });
298
+
299
+ it("a declared `website` field is an ordinary field, carried into payload", async () => {
300
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
301
+
302
+ await captureLeadAction(
303
+ INITIAL_STATE,
304
+ makeFormData({ ...VALID_FIELDS, website: "https://acme.example" }),
305
+ );
306
+
307
+ const body = mockSubmit.mock.calls[0][0] as { payload: Record<string, unknown> };
308
+ expect(body.payload.website).toBe("https://acme.example");
309
+ });
310
+ });