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
@@ -0,0 +1,141 @@
1
+ /**
2
+ * jobApplicationFormFromConfig (#8746 D4, D5; AC-4): the Job application
3
+ * magnet's field step mapped onto the shared form block, and the built-in
4
+ * form that stands in for an absent or switched-off magnet.
5
+ */
6
+ import {
7
+ JOB_APPLICATION_TERMS_STATEMENT,
8
+ jobApplicationFlowConfig,
9
+ type FlowConfig,
10
+ } from "@working-theory/validation";
11
+
12
+ import {
13
+ jobApplicationFormFallback,
14
+ jobApplicationFormFromConfig,
15
+ } from "../[slug]/job-application-form";
16
+
17
+ // jsdom has no `structuredClone`; the config is plain JSON.
18
+ const clone = (): FlowConfig =>
19
+ JSON.parse(JSON.stringify(jobApplicationFlowConfig)) as FlowConfig;
20
+
21
+ describe("jobApplicationFormFromConfig (#8746 D5, AC-4)", () => {
22
+ // #8840 D3 — the seed is two steps: step 1 (names, email, phone, the hidden
23
+ // `job`, the terms box) draws inline; LinkedIn URL moved to step 2, whose
24
+ // "Submit application" is now the submit label.
25
+ it("maps the seeded config's step 1 to First, Last, Email and Phone, with the step's title and step 2's submit label", () => {
26
+ const view = jobApplicationFormFromConfig(jobApplicationFlowConfig, "en");
27
+ expect(view?.title).toBe("Job application");
28
+ expect(view?.fields).toEqual([
29
+ {
30
+ name: "first_name",
31
+ type: "text",
32
+ label: "First name",
33
+ required: true,
34
+ colSpan: 1,
35
+ },
36
+ {
37
+ name: "last_name",
38
+ type: "text",
39
+ label: "Last name",
40
+ required: true,
41
+ colSpan: 1,
42
+ },
43
+ {
44
+ name: "email",
45
+ type: "email",
46
+ label: "Email",
47
+ required: true,
48
+ colSpan: 1,
49
+ },
50
+ {
51
+ name: "phone",
52
+ type: "tel",
53
+ label: "Phone",
54
+ required: false,
55
+ colSpan: 1,
56
+ },
57
+ ]);
58
+ expect(view?.submitLabel).toBe("Submit application");
59
+ });
60
+
61
+ it("keeps an owner's edited label and an added full-width field", () => {
62
+ const config = clone();
63
+ const step = config.steps[0] as Extract<
64
+ FlowConfig["steps"][number],
65
+ { type: "field" }
66
+ >;
67
+ step.fields[3]!.copy = { label: { en: "Mobile" } };
68
+ step.fields.push({
69
+ name: "cover_letter",
70
+ inputType: "textarea",
71
+ copy: { label: { en: "Cover letter" } },
72
+ });
73
+ const view = jobApplicationFormFromConfig(config, "en");
74
+ expect(view?.fields.map((f) => [f.name, f.label, f.colSpan])).toEqual([
75
+ ["first_name", "First name", 1],
76
+ ["last_name", "Last name", 1],
77
+ ["email", "Email", 1],
78
+ ["phone", "Mobile", 1],
79
+ ["cover_letter", "Cover letter", 2],
80
+ ]);
81
+ });
82
+
83
+ it.each(["first_name", "last_name", "email"])(
84
+ "answers null when the step has no %s field",
85
+ (name) => {
86
+ const config = clone();
87
+ const step = config.steps[0] as Extract<
88
+ FlowConfig["steps"][number],
89
+ { type: "field" }
90
+ >;
91
+ step.fields = step.fields.filter((f) => f.name !== name);
92
+ expect(jobApplicationFormFromConfig(config, "en")).toBeNull();
93
+ },
94
+ );
95
+
96
+ it("answers null when the first step is not a field step", () => {
97
+ const config = clone();
98
+ (config.steps[0] as { type: string }).type = "segmentation";
99
+ expect(jobApplicationFormFromConfig(config, "en")).toBeNull();
100
+ });
101
+ });
102
+
103
+ describe("a consent field on the magnet (#8746 D5)", () => {
104
+ it("labels the terms box with the step's first consent field, which is not drawn as a field", () => {
105
+ const config = clone();
106
+ const step = config.steps[0] as Extract<
107
+ FlowConfig["steps"][number],
108
+ { type: "field" }
109
+ >;
110
+ const terms = step.fields.find((f) => f.name === "terms")!;
111
+ terms.copy = {
112
+ label: {
113
+ en: "I agree my application is kept for 12 months under the [Privacy Policy](privacy).",
114
+ },
115
+ };
116
+ const view = jobApplicationFormFromConfig(config, "en");
117
+ expect(view?.consentStatement).toBe(
118
+ "I agree my application is kept for 12 months under the [Privacy Policy](privacy).",
119
+ );
120
+ expect(view?.fields.map((f) => f.name)).not.toContain("terms");
121
+ });
122
+
123
+ // #8840 D3 — the seed now carries a terms box, so the page labels its own.
124
+ it("the seeded config's terms field labels the box", () => {
125
+ expect(jobApplicationFormFallback("en").consentStatement).toBe(
126
+ JOB_APPLICATION_TERMS_STATEMENT,
127
+ );
128
+ });
129
+
130
+ it("never draws the hidden job field", () => {
131
+ expect(jobApplicationFormFallback("en").fields.map((f) => f.name)).not.toContain("job");
132
+ });
133
+ });
134
+
135
+ describe("jobApplicationFormFallback (#8746 D5, AC-4)", () => {
136
+ it("is the seeded config's own form", () => {
137
+ expect(jobApplicationFormFallback("en")).toEqual(
138
+ jobApplicationFormFromConfig(jobApplicationFlowConfig, "en"),
139
+ );
140
+ });
141
+ });
@@ -0,0 +1,424 @@
1
+ /**
2
+ * The careers set over the Job application magnet (#8746 D1, D5; #8849 D7,
3
+ * D8; AC-5, AC-6): the posting page carries an Apply button and no form, and
4
+ * the Free apply page, careers/[slug]/[action]/page.tsx, draws the form.
5
+ *
6
+ * Renders the REAL `LeadCaptureForm` bound to the REAL apply action, so the
7
+ * form, its fields and a submit are all visible. Mocked boundaries:
8
+ * `@working-theory/api-client` (`flows.get`, `leads.captureStatus`,
9
+ * `leads.submit`), `next/headers`, `next-intl/server` (over the real English
10
+ * catalog), the `_site.mdx` contact-email read, and the page-renderer's
11
+ * content loader (injected, and spied on for `lib/posting-title.ts`'s read).
12
+ */
13
+ import React from "react";
14
+ import {
15
+ act,
16
+ fireEvent,
17
+ render,
18
+ screen,
19
+ waitFor,
20
+ } from "@testing-library/react";
21
+ import {
22
+ jobApplicationFlowConfig,
23
+ type FlowConfig,
24
+ } from "@working-theory/validation";
25
+
26
+ jest.mock("next/navigation", () => ({
27
+ __esModule: true,
28
+ notFound: jest.fn(() => {
29
+ throw new Error("NEXT_NOT_FOUND");
30
+ }),
31
+ }));
32
+
33
+ jest.mock("next-mdx-remote/rsc", () => ({
34
+ __esModule: true,
35
+ MDXRemote: ({ source }: { source: string }) =>
36
+ React.createElement("div", { "data-testid": "mdx-rendered" }, source),
37
+ }));
38
+
39
+ jest.mock("@working-theory/api-client", () => ({
40
+ flows: { get: jest.fn() },
41
+ leads: { captureStatus: jest.fn(), submit: jest.fn() },
42
+ visitorForwardHeaders: () => undefined,
43
+ }));
44
+
45
+ jest.mock("next/headers", () => ({
46
+ __esModule: true,
47
+ headers: jest.fn().mockResolvedValue({ get: () => null }),
48
+ cookies: jest.fn().mockResolvedValue({ get: () => undefined }),
49
+ }));
50
+
51
+ jest.mock("next-intl/server", () => {
52
+ const messages = jest.requireActual("../../../../messages/en.json") as Record<
53
+ string,
54
+ Record<string, unknown>
55
+ >;
56
+ return {
57
+ getTranslations: async (arg: string | { namespace: string }) => {
58
+ const ns = messages[typeof arg === "string" ? arg : arg.namespace] ?? {};
59
+ return (key: string, values?: Record<string, string>) =>
60
+ String(ns[key] ?? key).replace(
61
+ /\{(\w+)\}/g,
62
+ (m, name: string) => values?.[name] ?? m,
63
+ );
64
+ },
65
+ };
66
+ });
67
+
68
+ // eslint-disable-next-line no-restricted-syntax -- test-seam mock: the real read is a node:fs frontmatter parse of content/_site.mdx; narrowest possible scope
69
+ jest.mock("../../contact/site-contact-email", () => ({
70
+ readSiteContactEmail: jest.fn().mockReturnValue("jobs@example.com"),
71
+ }));
72
+
73
+ import { readFileSync } from "node:fs";
74
+ import { join } from "node:path";
75
+
76
+ import { flows, leads } from "@working-theory/api-client";
77
+ import * as pageRenderer from "@working-theory/page-renderer";
78
+
79
+ import messages from "../../../../messages/en.json";
80
+
81
+ import CareersPage from "../page";
82
+ import JobPage from "../[slug]/page";
83
+ import * as jobPageModule from "../[slug]/page";
84
+ import ApplyPage, { generateMetadata as applyMetadata } from "../[slug]/[action]/page";
85
+
86
+ const mockFlowsGet = flows.get as jest.Mock;
87
+ const mockCaptureStatus = leads.captureStatus as jest.Mock;
88
+ const mockSubmit = leads.submit as jest.Mock;
89
+ let mockPostingSource: jest.SpyInstance;
90
+
91
+ type FieldStep = Extract<FlowConfig["steps"][number], { type: "field" }>;
92
+
93
+ const JOB = "writer";
94
+ const jobSource = {
95
+ frontmatter: {
96
+ type: "JobPosting" as const,
97
+ slug: JOB,
98
+ title: "Writer",
99
+ description: "Write for us.",
100
+ },
101
+ body: "mdx-body-content",
102
+ };
103
+
104
+ async function renderApplyPage(slug = JOB, action = "apply") {
105
+ const element = await ApplyPage({
106
+ params: Promise.resolve({ locale: "en", slug, action }),
107
+ });
108
+ return render(element);
109
+ }
110
+
111
+ /** The form's field names; a composite's parts (`field-phone-country`, …) are left out. */
112
+ function fieldNames(): string[] {
113
+ return Array.from(
114
+ screen
115
+ .getByTestId("lead-capture-form")
116
+ .querySelectorAll<HTMLElement>("[data-testid^='field-']"),
117
+ )
118
+ .map((el) => el.dataset.testid!.slice("field-".length))
119
+ .filter((name) => !name.includes("-"));
120
+ }
121
+
122
+ // The apply page shares its two hub reads for a few seconds, per module
123
+ // instance (as /contact does, #8731). Each test starts an hour later so none
124
+ // sees another's read.
125
+ let clock = 1_800_000_000_000;
126
+ const nowSpy = jest.spyOn(Date, "now");
127
+
128
+ beforeEach(() => {
129
+ clock += 60 * 60 * 1000;
130
+ nowSpy.mockImplementation(() => clock);
131
+ mockCaptureStatus.mockResolvedValue({
132
+ ok: true,
133
+ status: 200,
134
+ body: { ok: true, data: { contact: true, newsletter: true } },
135
+ });
136
+ mockFlowsGet.mockResolvedValue({
137
+ ok: true,
138
+ status: 200,
139
+ body: jobApplicationFlowConfig,
140
+ });
141
+ mockSubmit.mockResolvedValue({ ok: true, status: 201, body: { ok: true } });
142
+ // `lib/posting-title.ts` reads the posting through the package loader.
143
+ mockPostingSource = jest
144
+ .spyOn(pageRenderer, "getMdxSource")
145
+ .mockImplementation(async (_type, slug) => (slug === JOB ? jobSource : null));
146
+ });
147
+
148
+ afterEach(() => {
149
+ jest.clearAllMocks();
150
+ mockPostingSource.mockRestore();
151
+ });
152
+
153
+ describe("the posting page carries an Apply button and no form (#8849 D8, AC-5)", () => {
154
+ it("renders job-apply-cta to the localized apply page, and no form or hub read", async () => {
155
+ const element = await JobPage({
156
+ params: Promise.resolve({ locale: "fr", slug: JOB }),
157
+ getMdxSource: jest.fn().mockResolvedValue(jobSource),
158
+ });
159
+ render(element);
160
+ const cta = screen.getByTestId("job-apply-cta");
161
+ expect(cta.getAttribute("href")).toBe("/fr/careers/writer/apply");
162
+ expect(cta.textContent).toBe(messages.careers.apply);
163
+ expect(screen.queryByTestId("job-apply")).toBeNull();
164
+ expect(document.getElementById("apply")).toBeNull();
165
+ expect(screen.queryByTestId("lead-capture-form")).toBeNull();
166
+ expect(document.querySelector("form")).toBeNull();
167
+ expect(mockFlowsGet).not.toHaveBeenCalled();
168
+ expect(mockCaptureStatus).not.toHaveBeenCalled();
169
+ // The posting itself still renders above it.
170
+ expect(screen.getByTestId("mdx-rendered").textContent).toBe(
171
+ "mdx-body-content",
172
+ );
173
+ });
174
+
175
+ it("an unknown posting is a 404 and builds no button", async () => {
176
+ await expect(
177
+ JobPage({
178
+ params: Promise.resolve({ locale: "en", slug: "unknown" }),
179
+ getMdxSource: jest.fn().mockResolvedValue(null),
180
+ }),
181
+ ).rejects.toThrow("NEXT_NOT_FOUND");
182
+ expect(screen.queryByTestId("job-apply-cta")).toBeNull();
183
+ expect(mockFlowsGet).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it("the page module no longer exports dynamic", () => {
187
+ expect("dynamic" in jobPageModule).toBe(false);
188
+ });
189
+ });
190
+
191
+ describe("the Free apply page draws the Job application (#8849 D7, AC-6; #8746 D1, D5)", () => {
192
+ it("renders a back link to the posting, the posting's title and the magnet's field step", async () => {
193
+ await renderApplyPage();
194
+ expect(mockFlowsGet).toHaveBeenCalledWith("job-application");
195
+ expect(
196
+ screen.getByRole("heading", { level: 1, name: "Writer" }),
197
+ ).toBeTruthy();
198
+ expect(screen.getByTestId("nav-back-link").getAttribute("href")).toBe(
199
+ "/careers/writer",
200
+ );
201
+ expect(fieldNames()).toEqual([
202
+ "first_name",
203
+ "last_name",
204
+ "email",
205
+ "phone",
206
+ ]);
207
+ expect(screen.getByTestId("contact-submit").textContent).toBe(
208
+ "Submit application",
209
+ );
210
+ });
211
+
212
+ it("an owner's edit to the magnet shows on the page", async () => {
213
+ const edited = JSON.parse(
214
+ JSON.stringify(jobApplicationFlowConfig),
215
+ ) as FlowConfig;
216
+ (edited.steps[0] as FieldStep).fields[2]!.copy.label = { en: "Work email" };
217
+ mockFlowsGet.mockResolvedValue({ ok: true, status: 200, body: edited });
218
+ await renderApplyPage();
219
+ expect(
220
+ screen.getByTestId("field-email").closest("div")!.textContent,
221
+ ).toContain("Work email");
222
+ });
223
+
224
+ it.each([
225
+ [
226
+ "absent or off (404)",
227
+ () =>
228
+ mockFlowsGet.mockResolvedValue({ ok: false, status: 404, body: null }),
229
+ ],
230
+ [
231
+ "unreadable (throws)",
232
+ () => mockFlowsGet.mockRejectedValue(new Error("down")),
233
+ ],
234
+ [
235
+ "unusable (no first_name)",
236
+ () => {
237
+ const broken = JSON.parse(
238
+ JSON.stringify(jobApplicationFlowConfig),
239
+ ) as FlowConfig;
240
+ const step = broken.steps[0] as FieldStep;
241
+ step.fields = step.fields.filter((f) => f.name !== "first_name");
242
+ mockFlowsGet.mockResolvedValue({ ok: true, status: 200, body: broken });
243
+ },
244
+ ],
245
+ ])(
246
+ "falls back to the built-in form with the same fields when the magnet is %s",
247
+ async (_label, arrange) => {
248
+ arrange();
249
+ await renderApplyPage();
250
+ expect(fieldNames()).toEqual([
251
+ "first_name",
252
+ "last_name",
253
+ "email",
254
+ "phone",
255
+ ]);
256
+ },
257
+ );
258
+
259
+ it("shows the site's contact email instead of a form when the deployment accepts no lead", async () => {
260
+ mockCaptureStatus.mockResolvedValue({
261
+ ok: true,
262
+ status: 200,
263
+ body: { ok: true, data: { contact: false, newsletter: false } },
264
+ });
265
+ await renderApplyPage();
266
+ expect(screen.queryByTestId("lead-capture-form")).toBeNull();
267
+ expect(screen.getByTestId("job-apply").textContent).toContain(
268
+ "jobs@example.com",
269
+ );
270
+ });
271
+
272
+ it.each([
273
+ ["an action other than apply", JOB, "start"],
274
+ ["an uppercase slug", "Writer", "apply"],
275
+ ["an encoded traversal slug", "a%2F..%2Fb", "apply"],
276
+ ])("%s is a 404 before any read", async (_label, slug, action) => {
277
+ await expect(renderApplyPage(slug, action)).rejects.toThrow("NEXT_NOT_FOUND");
278
+ expect(mockPostingSource).not.toHaveBeenCalled();
279
+ expect(mockFlowsGet).not.toHaveBeenCalled();
280
+ expect(mockCaptureStatus).not.toHaveBeenCalled();
281
+ });
282
+
283
+ it("an unknown posting is a 404 with no hub read", async () => {
284
+ await expect(renderApplyPage("unknown")).rejects.toThrow("NEXT_NOT_FOUND");
285
+ expect(mockPostingSource).toHaveBeenCalledWith("JobPosting", "unknown", "en");
286
+ expect(mockFlowsGet).not.toHaveBeenCalled();
287
+ expect(mockCaptureStatus).not.toHaveBeenCalled();
288
+ });
289
+
290
+ it("is noindex, follow, titled with the posting", async () => {
291
+ const metadata = await applyMetadata({
292
+ params: Promise.resolve({ locale: "en", slug: JOB, action: "apply" }),
293
+ });
294
+ expect(metadata.robots).toEqual({ index: false, follow: true });
295
+ expect(metadata.title).toBe("Writer");
296
+ const unknown = await applyMetadata({
297
+ params: Promise.resolve({ locale: "en", slug: "unknown", action: "apply" }),
298
+ });
299
+ expect(unknown.robots).toEqual({ index: false, follow: true });
300
+ });
301
+
302
+ it("is core: it imports no flows/ or module file, and reads the title from lib/posting-title", () => {
303
+ const source = readFileSync(
304
+ join(__dirname, "..", "[slug]", "[action]", "page.tsx"),
305
+ "utf8",
306
+ );
307
+ const imports = source
308
+ .split("\n")
309
+ .filter((line) => /^import |^\} from |^ [\w{}, ]+ from /.test(line) || line.includes(" from \""))
310
+ .join("\n");
311
+ expect(imports).not.toMatch(/flows\//);
312
+ expect(imports).not.toContain("FlowStepper");
313
+ expect(imports).not.toContain("@working-theory/ui-signal-collectors");
314
+ expect(imports).not.toMatch(/["']\.\/apply|\/apply\//);
315
+ expect(imports).toContain('from "../../../../../lib/posting-title"');
316
+ });
317
+ });
318
+
319
+ describe("the Free apply page shares its two hub reads (#8746 security review, as #8731)", () => {
320
+ it("a burst of views makes one hub read of each kind, not one per view", async () => {
321
+ for (let i = 0; i < 25; i += 1) {
322
+ const view = await renderApplyPage();
323
+ expect(screen.getByTestId("lead-capture-form")).toBeTruthy();
324
+ view.unmount();
325
+ }
326
+ expect(mockFlowsGet).toHaveBeenCalledTimes(1);
327
+ expect(mockCaptureStatus).toHaveBeenCalledTimes(1);
328
+ });
329
+
330
+ it("a failed read is shared too, so an unreachable hub is not retried per view", async () => {
331
+ mockFlowsGet.mockRejectedValue(new Error("hub down"));
332
+ mockCaptureStatus.mockRejectedValue(new Error("hub down"));
333
+ for (let i = 0; i < 5; i += 1) {
334
+ const view = await renderApplyPage();
335
+ expect(fieldNames()).toEqual([
336
+ "first_name",
337
+ "last_name",
338
+ "email",
339
+ "phone",
340
+ ]);
341
+ view.unmount();
342
+ }
343
+ expect(mockFlowsGet).toHaveBeenCalledTimes(1);
344
+ expect(mockCaptureStatus).toHaveBeenCalledTimes(1);
345
+ });
346
+
347
+ it("an Admin edit shows once the shared read expires", async () => {
348
+ const first = await renderApplyPage();
349
+ expect(
350
+ screen.getByTestId("field-email").closest("div")!.textContent,
351
+ ).toContain("Email");
352
+ first.unmount();
353
+
354
+ const edited = JSON.parse(
355
+ JSON.stringify(jobApplicationFlowConfig),
356
+ ) as FlowConfig;
357
+ (edited.steps[0] as FieldStep).fields[2]!.copy.label = { en: "Work email" };
358
+ mockFlowsGet.mockResolvedValue({ ok: true, status: 200, body: edited });
359
+ clock += 60_000;
360
+ await renderApplyPage();
361
+ expect(
362
+ screen.getByTestId("field-email").closest("div")!.textContent,
363
+ ).toContain("Work email");
364
+ expect(mockFlowsGet).toHaveBeenCalledTimes(2);
365
+ });
366
+ });
367
+
368
+ describe("a submission from the Free apply page lands as a job application lead (#8746 D4, D5; #8849 AC-6)", () => {
369
+ it("posts source flow:job-application, name = first + last and the posting's slug as job", async () => {
370
+ await renderApplyPage();
371
+ for (const [name, value] of Object.entries({
372
+ first_name: "Ada",
373
+ last_name: "Lovelace",
374
+ email: "ada@example.com",
375
+ })) {
376
+ fireEvent.change(screen.getByTestId(`field-${name}`), {
377
+ target: { value },
378
+ });
379
+ }
380
+ fireEvent.click(screen.getByTestId("contact-consent"));
381
+ await act(async () => {
382
+ fireEvent.submit(screen.getByTestId("lead-capture-form"));
383
+ });
384
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(1));
385
+ const body = mockSubmit.mock.calls[0][0] as {
386
+ source: string;
387
+ email: string;
388
+ payload: Record<string, unknown>;
389
+ };
390
+ expect(body.source).toBe("flow:job-application");
391
+ expect(body.email).toBe("ada@example.com");
392
+ expect(body.payload).toMatchObject({
393
+ name: "Ada Lovelace",
394
+ job: JOB,
395
+ page: "careers",
396
+ });
397
+ // #8746 — the careers catalog's own success line, not /contact's.
398
+ const success = await screen.findByTestId("contact-success");
399
+ expect(success.textContent).toBe(messages.careers.applicationSuccess);
400
+ expect(success.textContent).not.toContain("reaching out");
401
+ });
402
+ });
403
+
404
+ describe("the /careers index links to each job and carries no form (#8746 D1, #8849 D10)", () => {
405
+ it("renders the list with no apply form and reads no magnet", async () => {
406
+ const element = await CareersPage({
407
+ params: Promise.resolve({ locale: "en" }),
408
+ getMdxSlugs: jest.fn().mockResolvedValue([]),
409
+ getMdxSource: jest.fn().mockResolvedValue({
410
+ frontmatter: {
411
+ type: "CareersList",
412
+ slug: "careers",
413
+ title: "Careers",
414
+ description: "Open roles.",
415
+ },
416
+ body: "",
417
+ }),
418
+ });
419
+ render(element);
420
+ expect(screen.queryByTestId("lead-capture-form")).toBeNull();
421
+ expect(document.getElementById("apply")).toBeNull();
422
+ expect(mockFlowsGet).not.toHaveBeenCalled();
423
+ });
424
+ });