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
@@ -43,6 +43,11 @@ import {
43
43
  isSignUpOpen,
44
44
  resetSendConfigured,
45
45
  } from "./lib/auth-env";
46
+ import {
47
+ signUpAgreementHooks,
48
+ signUpAgreementsPlugin,
49
+ withAuthTransactionCapture,
50
+ } from "./lib/sign-up-agreements";
46
51
 
47
52
  /**
48
53
  * Reset-token lifetime (#5962 § D11). One hour — the library's own default,
@@ -438,9 +443,25 @@ export function createAuth(deps: CreateAuthDeps = {}) {
438
443
  };
439
444
 
440
445
  const instance = betterAuth({
441
- database: prismaAdapter(prisma, {
446
+ /**
447
+ * `transaction: true` (#8765 D4): Better Auth opens one `$transaction` for
448
+ * exactly its three `runWithTransaction` sites — `/sign-up/email`,
449
+ * `createOAuthUser` and verification-token consumption — so a user, its
450
+ * account and its sign-up grants commit together or not at all. Every
451
+ * other write stays autocommit. The capture wrapper exposes that
452
+ * transaction's client to the sign-up hooks below; it changes nothing
453
+ * about the transaction itself (lib/sign-up-agreements.ts).
454
+ */
455
+ database: prismaAdapter(withAuthTransactionCapture(prisma), {
442
456
  provider: "postgresql",
457
+ transaction: true,
443
458
  }),
459
+ /**
460
+ * The sign-up grants (#8765 D4): the user hook decides whether the new
461
+ * user carries agreements, the account hook writes them on the captured
462
+ * transaction client. No other database hook is installed.
463
+ */
464
+ databaseHooks: signUpAgreementHooks(),
444
465
  secret: resolveSecret(),
445
466
  baseURL: process.env.BETTER_AUTH_URL,
446
467
  socialProviders: buildSocialProviders(),
@@ -653,13 +674,18 @@ export function createAuth(deps: CreateAuthDeps = {}) {
653
674
  * Sign-in only — the callback mails existing users and nobody else, and
654
675
  * `disableSignUp` refuses to create one even for a live token
655
676
  * (lib/auth-email.ts).
677
+ *
678
+ * `signUpAgreementsPlugin` always (#8765 D3): while sign-up is open,
679
+ * `/sign-up/email` without every sign-up agreement is refused before the
680
+ * handler looks the address up.
656
681
  */
657
682
  plugins: magicLinkActive
658
683
  ? [
659
684
  nextCookies(),
685
+ signUpAgreementsPlugin(),
660
686
  magicLink(makeMagicLinkOptions({ send: authEmailTransport })),
661
687
  ]
662
- : [nextCookies()],
688
+ : [nextCookies(), signUpAgreementsPlugin()],
663
689
  });
664
690
  return instance;
665
691
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * account-invite.test.ts — #8757 D8 / AC-5: the /contact thank-you's account
3
+ * link, by the generated sign-in provider. A site signs in at its own
4
+ * locale-prefixed `/sign-in` and lands on its own `/account`; a hub-sign-in
5
+ * spoke starts at `/api/hub-session/sign-in`; a site with no sign-in gets no
6
+ * link, and neither does an email the hint schema refuses.
7
+ */
8
+
9
+ let mockProvider: "site" | "hub" | "none" = "site";
10
+ // eslint-disable-next-line no-restricted-syntax -- #507: SIGN_IN_PROVIDER is a generated per-build constant (#8757 D8), not a runtime input; no env or HTTP surface selects it.
11
+ jest.mock("../topology.generated", () => ({
12
+ __esModule: true,
13
+ get SIGN_IN_PROVIDER() {
14
+ return mockProvider;
15
+ },
16
+ }));
17
+
18
+ import { accountInviteHref } from "../account-invite";
19
+
20
+ afterEach(() => {
21
+ mockProvider = "site";
22
+ });
23
+
24
+ describe("accountInviteHref (#8757 D8)", () => {
25
+ it("site, the default locale: the bare /sign-in with next=/account and the email", () => {
26
+ expect(accountInviteHref("en", "a@b.example")).toBe(
27
+ "/sign-in?next=%2Faccount&email=a%40b.example",
28
+ );
29
+ });
30
+
31
+ it("site, another locale: both the sign-in path and next carry the prefix", () => {
32
+ expect(accountInviteHref("fr", "a@b.example")).toBe(
33
+ "/fr/sign-in?next=%2Ffr%2Faccount&email=a%40b.example",
34
+ );
35
+ });
36
+
37
+ it("site: a plus sign in the address is encoded, never read as a space", () => {
38
+ expect(accountInviteHref("en", "a+b@x.example")).toBe(
39
+ "/sign-in?next=%2Faccount&email=a%2Bb%40x.example",
40
+ );
41
+ });
42
+
43
+ it("hub: the spoke's hub-session start, unprefixed", () => {
44
+ mockProvider = "hub";
45
+ expect(accountInviteHref("fr", "a@b.example")).toBe(
46
+ "/api/hub-session/sign-in?next=%2Faccount&email=a%40b.example",
47
+ );
48
+ });
49
+
50
+ it("none: no link", () => {
51
+ mockProvider = "none";
52
+ expect(accountInviteHref("en", "a@b.example")).toBeNull();
53
+ });
54
+
55
+ it.each(["x\r\n@y.example", "a&next=/evil@b.example", "not-an-email", ""])(
56
+ "an email the hint schema refuses (%j) gives no link, on either provider",
57
+ (email) => {
58
+ expect(accountInviteHref("en", email)).toBeNull();
59
+ mockProvider = "hub";
60
+ expect(accountInviteHref("en", email)).toBeNull();
61
+ },
62
+ );
63
+ });
@@ -7,7 +7,7 @@
7
7
  * and the absent-header fail-safe — and the same for `isAccountPath()` (#7430).
8
8
  */
9
9
 
10
- import { AUTH_SEGMENTS, isAccountPath, isAuthPath } from "../auth-routes";
10
+ import { AUTH_SEGMENTS, isAccountPath, isAuthPath, isBookingPath } from "../auth-routes";
11
11
 
12
12
  const LOCALES = ["en", "fr", "de"];
13
13
 
@@ -111,3 +111,29 @@ describe("isAccountPath (#7430, spec 7423 D4)", () => {
111
111
  expect(isAccountPath(null, LOCALES)).toBe(false);
112
112
  });
113
113
  });
114
+
115
+ // #8843 D10 — the booking pages render without the marketing chrome.
116
+ describe("isBookingPath (#8843)", () => {
117
+ it("matches /meet, a meeting page and /private, bare or locale-prefixed", () => {
118
+ for (const path of ["/meet", "/meet/", "/meet/intro", "/en/meet/intro", "/fr/meet", "/private", "/fr/private"]) {
119
+ expect(isBookingPath(path, LOCALES)).toBe(true);
120
+ }
121
+ });
122
+
123
+ it("does not match the confirmation page, which keeps the marketing chrome", () => {
124
+ expect(isBookingPath("/meet/confirmed", LOCALES)).toBe(false);
125
+ expect(isBookingPath("/fr/meet/confirmed", LOCALES)).toBe(false);
126
+ });
127
+
128
+ it("does not match a near miss or a marketing path", () => {
129
+ expect(isBookingPath("/meeting", LOCALES)).toBe(false);
130
+ expect(isBookingPath("/privacy", LOCALES)).toBe(false);
131
+ expect(isBookingPath("/blog/meet", LOCALES)).toBe(false);
132
+ });
133
+
134
+ it("fails to NOT-a-booking-path for an empty or absent pathname", () => {
135
+ expect(isBookingPath("", LOCALES)).toBe(false);
136
+ expect(isBookingPath(undefined, LOCALES)).toBe(false);
137
+ expect(isBookingPath(null, LOCALES)).toBe(false);
138
+ });
139
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The localized policy map (#8765 D9, AC-5): one map, the registry's keys,
3
+ * each path carrying the locale prefix off the default locale.
4
+ */
5
+ import { consentPoliciesFor } from "../consent-policies";
6
+
7
+ describe("consentPoliciesFor", () => {
8
+ it("is /contact's three destinations for the default locale", () => {
9
+ expect(consentPoliciesFor("en")).toEqual({
10
+ terms: "/terms",
11
+ privacy: "/privacy",
12
+ working_agreement: "/working-agreement",
13
+ });
14
+ });
15
+
16
+ it("prefixes every path for another locale", () => {
17
+ expect(consentPoliciesFor("fr")).toEqual({
18
+ terms: "/fr/terms",
19
+ privacy: "/fr/privacy",
20
+ working_agreement: "/fr/working-agreement",
21
+ });
22
+ });
23
+ });
@@ -69,8 +69,9 @@ describe("deckCandidates (#8687 D3, AC-2)", () => {
69
69
  ]);
70
70
  });
71
71
 
72
- it("/careers (gateable, no DECK_ROUTES entry) falls through to [slug]", () => {
72
+ it("/careers (sections mode since #8849) resolves the route row, then the [slug] fallback", () => {
73
73
  expect(deckCandidates("/careers", LOCALES)).toEqual([
74
+ { type: "CareersList", slug: "careers" },
74
75
  { type: "LandingPage", slug: "careers" },
75
76
  { type: "BlogList", slug: "careers" },
76
77
  ]);
@@ -197,10 +198,15 @@ describe("the registered inputsFor (#8687 D3, AC-2)", () => {
197
198
  const chrome = siteChrome as typeof siteChrome & {
198
199
  deckGrounds?: unknown;
199
200
  deckPresentation?: true;
201
+ deckAnchors?: "bare";
200
202
  };
201
203
  expect(result).toEqual({
202
204
  ...(chrome.deckGrounds !== undefined ? { deckGrounds: chrome.deckGrounds } : {}),
203
205
  deckPresentation: chrome.deckPresentation === true,
206
+ // #8773 — the design's slide naming rides along only when it presents and chose bare.
207
+ ...(chrome.deckPresentation === true && chrome.deckAnchors === "bare"
208
+ ? { deckAnchorStyle: "bare" }
209
+ : {}),
204
210
  });
205
211
  });
206
212
 
@@ -0,0 +1,265 @@
1
+ /**
2
+ * lib/in-page-magnet.ts — the in-page form shape (#8847 D6, D15; AC-2).
3
+ *
4
+ * Pure mapping and loader wrapping; the two hub reads are mocked at
5
+ * `@working-theory/api-client`, and the Server Action modules are stubbed so
6
+ * the bound action is asserted by identity.
7
+ */
8
+ import { LeadCaptureForm } from "@working-theory/ui";
9
+ import {
10
+ contactFormFlowConfig,
11
+ newsletterFlowConfig,
12
+ type FlowConfig,
13
+ } from "@working-theory/validation";
14
+
15
+ jest.mock("@working-theory/api-client", () => ({
16
+ flows: { get: jest.fn() },
17
+ leads: { captureStatus: jest.fn() },
18
+ }));
19
+
20
+ // eslint-disable-next-line no-restricted-syntax -- test-seam mock: a "use server" boundary the module only binds
21
+ jest.mock("../../app/[locale]/contact/contact.actions", () => ({
22
+ captureLeadAction: jest.fn(),
23
+ }));
24
+ // eslint-disable-next-line no-restricted-syntax -- test-seam mock: a "use server" boundary the module only binds
25
+ jest.mock("../newsletter.actions", () => ({
26
+ subscribeNewsletterAction: jest.fn(),
27
+ }));
28
+
29
+ import { flows } from "@working-theory/api-client";
30
+
31
+ import { captureLeadAction } from "../../app/[locale]/contact/contact.actions";
32
+ import {
33
+ fillEndingName,
34
+ framedSources,
35
+ inPageView,
36
+ readServedMagnet,
37
+ renderInPageMagnet,
38
+ type InPageView,
39
+ } from "../in-page-magnet";
40
+
41
+ const mockFlowsGet = flows.get as jest.Mock;
42
+
43
+ type FieldStep = Extract<FlowConfig["steps"][number], { type: "field" }>;
44
+
45
+ /** A request-a-quote shaped contact magnet: name, email, need. */
46
+ const QUOTE: FlowConfig = {
47
+ slug: "quote",
48
+ defaultLocale: "en",
49
+ steps: [
50
+ {
51
+ key: "quote",
52
+ type: "field",
53
+ copy: { title: { en: "Request a quote" }, body: { en: "Tell us the job." }, cta: { en: "Ask" } },
54
+ fields: [
55
+ { name: "name", inputType: "text", required: true, mapsTo: "name", copy: { label: { en: "Name" } } },
56
+ { name: "email", inputType: "email", required: true, mapsTo: "email", copy: { label: { en: "Email" } } },
57
+ { name: "need", inputType: "textarea", required: false, copy: { label: { en: "What do you need?" } } },
58
+ ],
59
+ },
60
+ ],
61
+ };
62
+
63
+ const TEXT = {
64
+ unavailableHeading: "Unavailable",
65
+ unavailableBody: "Write to {email}.",
66
+ unavailableNoEmail: "No email.",
67
+ };
68
+
69
+ describe("fillEndingName (#8841 D1's rule)", () => {
70
+ it("drops {name} with its comma when there is no name, and fills it when there is", () => {
71
+ expect(fillEndingName("Thanks, {name}.", "")).toBe("Thanks.");
72
+ expect(fillEndingName("Thanks, {name}.", "Ada")).toBe("Thanks, Ada.");
73
+ });
74
+ });
75
+
76
+ describe("inPageView — the form comes from the caller (C1)", () => {
77
+ it("a quote-shaped config asked for as contact maps its fields, title and body", () => {
78
+ const view = inPageView(QUOTE, "en", "contact")!;
79
+ expect(view.title).toBe("Request a quote");
80
+ expect(view.body).toBe("Tell us the job.");
81
+ expect(view.stepKey).toBe("quote");
82
+ expect("fields" in view.form && view.form.fields.map((f) => f.name)).toEqual(["name", "email", "need"]);
83
+ });
84
+
85
+ it("the newsletter config asked for as contact is null — it has no name field", () => {
86
+ expect(inPageView(newsletterFlowConfig, "en", "contact")).toBeNull();
87
+ });
88
+
89
+ it("the newsletter shape served at another slug maps when the caller asks for newsletter", () => {
90
+ const updates = { ...newsletterFlowConfig, slug: "updates" };
91
+ const view = inPageView(updates, "en", "newsletter")!;
92
+ expect(view.form).toMatchObject({
93
+ kind: "newsletter",
94
+ emailField: "email",
95
+ label: "Email",
96
+ placeholder: "you@example.com",
97
+ consent: { field: "terms", purposes: ["newsletter"], help: "No spam. Unsubscribe any time." },
98
+ submitLabel: "Subscribe",
99
+ });
100
+ expect(view.ending).toEqual({
101
+ kind: "screen",
102
+ headline: "Check your inbox",
103
+ body: "Your welcome email is on its way.",
104
+ });
105
+ });
106
+
107
+ it("a newsletter view is null with any other field to fill in, or a second step", () => {
108
+ const extra = JSON.parse(JSON.stringify(newsletterFlowConfig)) as FlowConfig;
109
+ (extra.steps[0] as FieldStep).fields.push({
110
+ name: "company",
111
+ inputType: "text",
112
+ copy: { label: { en: "Company" } },
113
+ });
114
+ expect(inPageView(extra, "en", "newsletter")).toBeNull();
115
+ const twoSteps = { ...newsletterFlowConfig, steps: [...newsletterFlowConfig.steps, ...QUOTE.steps] };
116
+ expect(inPageView(twoSteps, "en", "newsletter")).toBeNull();
117
+ });
118
+
119
+ it("the Contact form's ending is its config's; a config with none ends on the caller's line", () => {
120
+ expect(inPageView(contactFormFlowConfig, "en", "contact")!.ending).toEqual({
121
+ kind: "screen",
122
+ headline: "Message sent.",
123
+ body: "Thanks for getting in touch. I reply within one business day.",
124
+ });
125
+ expect(inPageView(QUOTE, "en", "contact", "Thank you!")!.ending).toEqual({
126
+ kind: "screen",
127
+ headline: "Thank you!",
128
+ });
129
+ });
130
+
131
+ it("an off-site redirect or button is dropped, never rendered (security review)", () => {
132
+ const offSite = {
133
+ ...QUOTE,
134
+ ending: { default: { kind: "redirect" as const, href: "https://evil.example/x" } },
135
+ } as FlowConfig;
136
+ expect(inPageView(offSite, "en", "contact", "Thanks")!.ending).toEqual({ kind: "screen", headline: "Thanks" });
137
+ const buttons = {
138
+ ...QUOTE,
139
+ ending: {
140
+ default: {
141
+ kind: "screen" as const,
142
+ headline: { en: "Done" },
143
+ ctas: [
144
+ { label: { en: "Evil" }, href: "//evil.example" },
145
+ { label: { en: "Home" }, href: "/" },
146
+ ],
147
+ },
148
+ },
149
+ } as FlowConfig;
150
+ expect(inPageView(buttons, "en", "contact")!.ending).toEqual({
151
+ kind: "screen",
152
+ headline: "Done",
153
+ ctas: [{ label: "Home", href: "/" }],
154
+ });
155
+ });
156
+
157
+ it("a redirect ending and a screen's buttons are localized", () => {
158
+ const redirect = { ...QUOTE, ending: { default: { kind: "redirect" as const, href: "/thanks" } } };
159
+ expect(inPageView(redirect, "fr", "contact")!.ending).toEqual({ kind: "redirect", href: "/fr/thanks" });
160
+ });
161
+ });
162
+
163
+ describe("framedSources (D6)", () => {
164
+ const source = { frontmatter: { type: "ContactPage", slug: "contact", title: "Old title" }, body: "" };
165
+ const view: Pick<InPageView, "title" | "body"> = { title: "Say hello", body: "Two days, tops." };
166
+
167
+ it("with no contact-form section both loaders' output is unchanged", async () => {
168
+ const sections = [{ id: "faq", type: "faq", data: { items: [] } }];
169
+ const framed = framedSources({
170
+ getMdxSource: jest.fn().mockResolvedValue(source),
171
+ getPageSections: jest.fn().mockResolvedValue(sections),
172
+ view,
173
+ });
174
+ expect(await framed.getMdxSource("ContactPage", "contact", "en")).toBe(source);
175
+ expect(await framed.getPageSections(source.frontmatter)).toBe(sections);
176
+ });
177
+
178
+ it("a leading contact-form section takes the view's title and body, and so does the frontmatter title", async () => {
179
+ const sections = [
180
+ { id: "contact", type: "contact-form", data: { variant: "column", headline: "Old", description: "Old line", fields: [] } },
181
+ ];
182
+ const framed = framedSources({
183
+ getMdxSource: jest.fn().mockResolvedValue(source),
184
+ getPageSections: jest.fn().mockResolvedValue(sections),
185
+ view,
186
+ });
187
+ expect((await framed.getMdxSource("ContactPage", "contact", "en"))!.frontmatter.title).toBe("Say hello");
188
+ expect((await framed.getPageSections(source.frontmatter))[0]!.data).toMatchObject({
189
+ headline: "Say hello",
190
+ description: "Two days, tops.",
191
+ });
192
+ });
193
+
194
+ it("a view with no body deletes the section's description", async () => {
195
+ const sections = [{ id: "c", type: "contact-form", data: { description: "Old line", fields: [] } }];
196
+ const framed = framedSources({
197
+ getMdxSource: jest.fn().mockResolvedValue(source),
198
+ getPageSections: jest.fn().mockResolvedValue(sections),
199
+ view: { title: "Say hello" },
200
+ });
201
+ expect((await framed.getPageSections(source.frontmatter))[0]!.data).not.toHaveProperty("description");
202
+ });
203
+ });
204
+
205
+ describe("renderInPageMagnet (D15)", () => {
206
+ it("a quote magnet asked for as contact binds captureLeadAction, stamped with its flow source", () => {
207
+ const view = inPageView(QUOTE, "en", "contact")!;
208
+ const element = renderInPageMagnet({
209
+ slug: "quote",
210
+ locale: "en",
211
+ view,
212
+ text: { ...TEXT } as never,
213
+ accepting: true,
214
+ });
215
+ expect(element.type).toBe(LeadCaptureForm);
216
+ expect(element.props).toMatchObject({
217
+ action: captureLeadAction,
218
+ // captureLeadAction keeps only CONTACT_LEAD_SOURCES, so this lands as contact-form.
219
+ source: "flow:quote",
220
+ look: "column",
221
+ ending: view.ending,
222
+ });
223
+ });
224
+
225
+ it("capture off is ContactFallback with the site's email", () => {
226
+ const element = renderInPageMagnet({
227
+ slug: "quote",
228
+ locale: "en",
229
+ view: inPageView(QUOTE, "en", "contact")!,
230
+ text: { ...TEXT } as never,
231
+ accepting: false,
232
+ fallbackEmail: "hi@x.co",
233
+ });
234
+ expect(element.props).toMatchObject({ email: "hi@x.co" });
235
+ });
236
+ });
237
+
238
+ describe("a config with no ending (review M1)", () => {
239
+ it("has no ending unless the caller gives a fallback line", () => {
240
+ expect(inPageView(QUOTE, "en", "contact")).not.toHaveProperty("ending");
241
+ });
242
+ });
243
+
244
+ describe("readServedMagnet", () => {
245
+ it("a hub that never answers is null after the deadline, not a hung page (review H2)", async () => {
246
+ jest.useFakeTimers();
247
+ try {
248
+ mockFlowsGet.mockReturnValueOnce(new Promise(() => undefined));
249
+ const read = readServedMagnet("stalled");
250
+ jest.advanceTimersByTime(2_000);
251
+ expect(await read).toBeNull();
252
+ } finally {
253
+ jest.useRealTimers();
254
+ }
255
+ });
256
+
257
+ it("is the served config, and null for a non-2xx or a throw", async () => {
258
+ mockFlowsGet.mockResolvedValueOnce({ ok: true, status: 200, body: QUOTE });
259
+ expect(await readServedMagnet("quote")).toBe(QUOTE);
260
+ mockFlowsGet.mockResolvedValueOnce({ ok: false, status: 404, body: {} });
261
+ expect(await readServedMagnet("absent")).toBeNull();
262
+ mockFlowsGet.mockRejectedValueOnce(new Error("hub down"));
263
+ expect(await readServedMagnet("down")).toBeNull();
264
+ });
265
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * #8840 D10, AC-4 — the magnet walk predicate the layout drops the chrome on.
3
+ */
4
+ import { isMagnetWalkPath } from "../magnet-routes";
5
+
6
+ const LOCALES = ["en", "fr"];
7
+
8
+ describe("isMagnetWalkPath (#8840 D10)", () => {
9
+ it.each([
10
+ "/diagnostic/start",
11
+ "/fr/diagnostic/start",
12
+ "/flows/a/start",
13
+ "/careers/apply",
14
+ "/careers/writer/apply",
15
+ "/fr/careers/writer/apply",
16
+ "/diagnostic/start?step=2",
17
+ ])("is a walk: %s", (path) => {
18
+ expect(isMagnetWalkPath(path, LOCALES)).toBe(true);
19
+ });
20
+
21
+ it.each([
22
+ "/diagnostic",
23
+ "/careers",
24
+ "/careers/writer",
25
+ "/start-here",
26
+ "/fr",
27
+ "/",
28
+ "",
29
+ "/apply",
30
+ "/careers/a/b/apply",
31
+ ])("is not a walk: %j", (path) => {
32
+ expect(isMagnetWalkPath(path, LOCALES)).toBe(false);
33
+ });
34
+
35
+ it("answers false for an absent pathname", () => {
36
+ expect(isMagnetWalkPath(undefined, LOCALES)).toBe(false);
37
+ expect(isMagnetWalkPath(null, LOCALES)).toBe(false);
38
+ });
39
+
40
+ it("treats a prefix that is not an enabled locale as a first segment", () => {
41
+ expect(isMagnetWalkPath("/de/careers/apply", LOCALES)).toBe(false);
42
+ expect(isMagnetWalkPath("/de/start", LOCALES)).toBe(true);
43
+ });
44
+ });
45
+
46
+ describe("isMagnetWalkPath — the deliverable pages (#8848 D9, AC-4)", () => {
47
+ it.each(["/en/reports/q3", "/reports/q3", "/reports/q3/start", "/fr/reports/q3/start"])(
48
+ "is chrome-free: %s",
49
+ (path) => {
50
+ expect(isMagnetWalkPath(path, ["en", "fr"])).toBe(true);
51
+ },
52
+ );
53
+
54
+ it.each(["/reports", "/en/reports", "/en/reports/"])("keeps the index's chrome: %s", (path) => {
55
+ expect(isMagnetWalkPath(path, ["en", "fr"])).toBe(false);
56
+ });
57
+ });