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,319 @@
1
+ /**
2
+ * The job page's apply action (#8746 D4, D5; AC-5): a submission writes one
3
+ * lead through the site's lead writer with source `flow:job-application`,
4
+ * `name` = first + last, and the posting's slug as the hidden `job` value.
5
+ */
6
+
7
+ import {
8
+ __resetRateLimit,
9
+ checkRateLimit,
10
+ } from "../../../../src/lib/rate-limit";
11
+ import { captureLeadAction } from "../[slug]/apply.actions";
12
+
13
+ jest.mock("@working-theory/api-client", () => ({
14
+ leads: { submit: jest.fn() },
15
+ visitorForwardHeaders: jest.requireActual("@working-theory/api-client")
16
+ .visitorForwardHeaders,
17
+ }));
18
+
19
+ jest.mock("next/headers", () => ({
20
+ __esModule: true,
21
+ headers: jest.fn().mockResolvedValue({ get: () => null }),
22
+ cookies: jest.fn().mockResolvedValue({ get: () => undefined }),
23
+ }));
24
+
25
+ const mockGetLocale = jest.fn();
26
+ const mockGetTranslations = jest.fn();
27
+ jest.mock("next-intl/server", () => ({
28
+ getLocale: (...args: unknown[]) => mockGetLocale(...args),
29
+ getTranslations: (...args: unknown[]) => mockGetTranslations(...args),
30
+ }));
31
+
32
+ const mockInviteHref = jest.fn();
33
+ // eslint-disable-next-line no-restricted-syntax -- accountInviteHref reads the generated topology (build output); this file asserts the action's own decision
34
+ jest.mock("../../../../lib/account-invite", () => ({
35
+ accountInviteHref: (...args: unknown[]) => mockInviteHref(...args),
36
+ }));
37
+
38
+ import { leads } from "@working-theory/api-client";
39
+ import { headers } from "next/headers";
40
+ const mockSubmit = leads.submit as jest.Mock;
41
+ const mockHeaders = headers as jest.Mock;
42
+
43
+ const JOB = "content-strategist";
44
+
45
+ function form(fields: Record<string, string>): FormData {
46
+ const fd = new FormData();
47
+ for (const [key, value] of Object.entries(fields)) fd.append(key, value);
48
+ return fd;
49
+ }
50
+
51
+ const VALID = {
52
+ first_name: "Ada",
53
+ last_name: "Lovelace",
54
+ email: "ada@example.com",
55
+ consent: "true",
56
+ };
57
+
58
+ type SubmitBody = {
59
+ source: string;
60
+ email: string;
61
+ consent?: boolean;
62
+ payload: Record<string, unknown>;
63
+ };
64
+ const submitted = (): SubmitBody => mockSubmit.mock.calls[0][0] as SubmitBody;
65
+
66
+ beforeEach(() => {
67
+ mockSubmit.mockResolvedValue({ ok: true, status: 201, body: { ok: true } });
68
+ mockGetLocale.mockResolvedValue("en");
69
+ mockGetTranslations.mockResolvedValue((key: string) =>
70
+ key === "trackInAccount" ? "Track this in your account" : key,
71
+ );
72
+ mockInviteHref.mockReturnValue("/sign-in?next=%2Faccount&email=ada%40example.com");
73
+ });
74
+
75
+ afterEach(() => {
76
+ jest.clearAllMocks();
77
+ mockHeaders.mockResolvedValue({ get: () => null });
78
+ __resetRateLimit();
79
+ delete process.env.AILK_LEAD_SITE;
80
+ });
81
+
82
+ describe("captureLeadAction on a job page (#8746 AC-5)", () => {
83
+ it("writes one flow:job-application lead with name = first + last and the hidden job slug", async () => {
84
+ const result = await captureLeadAction(JOB, null, form(VALID));
85
+
86
+ expect(result).toEqual({ kind: "success" });
87
+ expect(mockSubmit).toHaveBeenCalledTimes(1);
88
+ const body = submitted();
89
+ expect(body.source).toBe("flow:job-application");
90
+ expect(body.email).toBe("ada@example.com");
91
+ expect(body.payload).toEqual({
92
+ name: "Ada Lovelace",
93
+ first_name: "Ada",
94
+ last_name: "Lovelace",
95
+ job: JOB,
96
+ page: "careers",
97
+ });
98
+ expect(body).not.toHaveProperty("siteId");
99
+ });
100
+
101
+ it("sends the accepted terms as the body's top-level consent: true, never inside payload (#9130)", async () => {
102
+ await captureLeadAction(JOB, null, form(VALID));
103
+
104
+ const body = submitted();
105
+ expect(body.consent).toBe(true);
106
+ expect(body.payload).not.toHaveProperty("consent");
107
+ });
108
+
109
+ it("carries phone and a LinkedIn URL, a bare domain gaining https://", async () => {
110
+ await captureLeadAction(
111
+ JOB,
112
+ null,
113
+ form({
114
+ ...VALID,
115
+ phone: "+1 555 0100",
116
+ linkedin_url: "linkedin.com/in/ada",
117
+ }),
118
+ );
119
+ expect(submitted().payload).toMatchObject({
120
+ phone: "+1 555 0100",
121
+ linkedin_url: "https://linkedin.com/in/ada",
122
+ });
123
+ });
124
+
125
+ it("keeps an extra field the owner added to the magnet, and no field overrides job, name or source", async () => {
126
+ await captureLeadAction(
127
+ JOB,
128
+ null,
129
+ form({
130
+ ...VALID,
131
+ cover_letter: "Hello",
132
+ job: "other-job",
133
+ name: "Mallory",
134
+ source: "web",
135
+ }),
136
+ );
137
+ const body = submitted();
138
+ expect(body.source).toBe("flow:job-application");
139
+ expect(body.payload).toMatchObject({
140
+ cover_letter: "Hello",
141
+ job: JOB,
142
+ name: "Ada Lovelace",
143
+ });
144
+ });
145
+
146
+ it.each([
147
+ ["first_name", { ...VALID, first_name: " " }],
148
+ ["last_name", { ...VALID, last_name: "" }],
149
+ ["email", { ...VALID, email: "not-an-email" }],
150
+ ["linkedin_url", { ...VALID, linkedin_url: "not a url" }],
151
+ ["linkedin_url", { ...VALID, linkedin_url: "javascript:alert(1)" }],
152
+ [
153
+ "linkedin_url",
154
+ { ...VALID, linkedin_url: "https://linkedin.com@evil.example/in/ada" },
155
+ ],
156
+ ["consent", { ...VALID, consent: "" }],
157
+ ])(
158
+ "refuses a bad %s with a field error and sends nothing",
159
+ async (field, fields) => {
160
+ const result = await captureLeadAction(JOB, null, form(fields));
161
+ expect(result).toMatchObject({
162
+ kind: "error",
163
+ fieldErrors: { [field]: expect.any(String) },
164
+ });
165
+ expect(mockSubmit).not.toHaveBeenCalled();
166
+ },
167
+ );
168
+
169
+ it("refuses a job value that is not a slug, sending nothing", async () => {
170
+ const result = await captureLeadAction("../../etc", null, form(VALID));
171
+ expect(result).toMatchObject({ kind: "error" });
172
+ expect(mockSubmit).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it.each([[["eng-lead"]], [123]])(
176
+ "refuses a bound job of the wrong type (%p), sending nothing",
177
+ async (job) => {
178
+ // eslint-disable-next-line no-restricted-syntax -- a tampered bound argument is exactly a value of the wrong type
179
+ const tampered = job as unknown as string;
180
+ const result = await captureLeadAction(tampered, null, form(VALID));
181
+ expect(result).toMatchObject({ kind: "error" });
182
+ expect(mockSubmit).not.toHaveBeenCalled();
183
+ },
184
+ );
185
+
186
+ it("reads host:port without a scheme as a bare domain", async () => {
187
+ await captureLeadAction(
188
+ JOB,
189
+ null,
190
+ form({ ...VALID, linkedin_url: "www.linkedin.com:443/in/ada" }),
191
+ );
192
+ expect(submitted().payload).toMatchObject({
193
+ linkedin_url: "https://www.linkedin.com:443/in/ada",
194
+ });
195
+ });
196
+
197
+ it("shares /contact's per-visitor bucket", async () => {
198
+ mockHeaders.mockResolvedValue({
199
+ get: (name: string) =>
200
+ name === "x-forwarded-for" ? "203.0.113.7" : null,
201
+ });
202
+ for (let i = 0; i < 30; i += 1)
203
+ checkRateLimit("contact:203.0.113.7", 30, 10 * 60 * 1000);
204
+ expect(await captureLeadAction(JOB, null, form(VALID))).toMatchObject({
205
+ kind: "error",
206
+ });
207
+ expect(mockSubmit).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it("answers a filled honeypot as success and sends nothing", async () => {
211
+ const result = await captureLeadAction(
212
+ JOB,
213
+ null,
214
+ form({ ...VALID, ailk_hp: "bot" }),
215
+ );
216
+ expect(result).toEqual({ kind: "success" });
217
+ expect(mockSubmit).not.toHaveBeenCalled();
218
+ });
219
+
220
+ it("maps lead_capture_not_configured and an unreachable API to unavailable", async () => {
221
+ mockSubmit.mockResolvedValueOnce({
222
+ ok: false,
223
+ status: 503,
224
+ body: { error: "lead_capture_not_configured" },
225
+ });
226
+ expect(await captureLeadAction(JOB, null, form(VALID))).toEqual({
227
+ kind: "unavailable",
228
+ });
229
+ mockSubmit.mockRejectedValueOnce(new Error("fetch failed"));
230
+ expect(await captureLeadAction(JOB, null, form(VALID))).toEqual({
231
+ kind: "unavailable",
232
+ });
233
+ });
234
+
235
+ it("answers any other refusal with the generic error", async () => {
236
+ mockSubmit.mockResolvedValueOnce({
237
+ ok: false,
238
+ status: 400,
239
+ body: { error: "invalid" },
240
+ });
241
+ expect(await captureLeadAction(JOB, null, form(VALID))).toMatchObject({
242
+ kind: "error",
243
+ message: expect.any(String),
244
+ });
245
+ });
246
+
247
+ it("limits one visitor to 30 applications per 10 minutes", async () => {
248
+ mockHeaders.mockResolvedValue({
249
+ get: (name: string) =>
250
+ name === "x-forwarded-for" ? "203.0.113.9" : null,
251
+ });
252
+ for (let i = 0; i < 30; i += 1) {
253
+ expect(await captureLeadAction(JOB, null, form(VALID))).toEqual({
254
+ kind: "success",
255
+ });
256
+ }
257
+ expect(await captureLeadAction(JOB, null, form(VALID))).toMatchObject({
258
+ kind: "error",
259
+ });
260
+ expect(mockSubmit).toHaveBeenCalledTimes(30);
261
+ });
262
+ });
263
+
264
+ describe("the thank-you's account link (#8771 AC-6)", () => {
265
+ const invite = () =>
266
+ mockSubmit.mockResolvedValue({
267
+ ok: true,
268
+ status: 201,
269
+ body: { ok: true, next: { completion: "invite" } },
270
+ });
271
+
272
+ it("an invite answer returns the prefilled link and label", async () => {
273
+ invite();
274
+
275
+ const result = await captureLeadAction(JOB, null, form(VALID));
276
+
277
+ expect(result).toEqual({
278
+ kind: "success",
279
+ next: {
280
+ href: "/sign-in?next=%2Faccount&email=ada%40example.com",
281
+ label: "Track this in your account",
282
+ },
283
+ });
284
+ expect(mockInviteHref).toHaveBeenCalledWith("en", "ada@example.com");
285
+ });
286
+
287
+ it.each([
288
+ ["no next", { ok: true }],
289
+ ["a checkout next", { ok: true, next: { completion: "checkout", checkoutOffering: "retainer" } }],
290
+ ])("%s: exactly { kind: 'success' }", async (_name, body) => {
291
+ mockSubmit.mockResolvedValue({ ok: true, status: 201, body });
292
+
293
+ await expect(captureLeadAction(JOB, null, form(VALID))).resolves.toEqual({ kind: "success" });
294
+ expect(mockInviteHref).not.toHaveBeenCalled();
295
+ });
296
+
297
+ it("a site with no sign-in (href null) is exactly { kind: 'success' }", async () => {
298
+ invite();
299
+ mockInviteHref.mockReturnValue(null);
300
+ await expect(captureLeadAction(JOB, null, form(VALID))).resolves.toEqual({ kind: "success" });
301
+ });
302
+
303
+ it.each([
304
+ ["getLocale", () => mockGetLocale.mockRejectedValue(new Error("x"))],
305
+ ["accountInviteHref", () => mockInviteHref.mockImplementation(() => { throw new Error("x"); })],
306
+ ["getTranslations", () => mockGetTranslations.mockRejectedValue(new Error("x"))],
307
+ ])("a throw from %s after the write is still a success", async (_name, arm) => {
308
+ invite();
309
+ arm();
310
+ await expect(captureLeadAction(JOB, null, form(VALID))).resolves.toEqual({ kind: "success" });
311
+ expect(mockSubmit).toHaveBeenCalledTimes(1);
312
+ });
313
+
314
+ it("a filled honeypot is exactly { kind: 'success' } and writes nothing", async () => {
315
+ const result = await captureLeadAction(JOB, null, form({ ...VALID, ailk_hp: "http://x" }));
316
+ expect(result).toEqual({ kind: "success" });
317
+ expect(mockSubmit).not.toHaveBeenCalled();
318
+ });
319
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * /careers in sections mode (#8849 D10, AC-4): one generated `items` section
3
+ * from the JobPosting records, first, searchable and filterable by employment
4
+ * type and location, the facets read from the postings' first-class
5
+ * frontmatter fields.
6
+ *
7
+ * `renderPage` is spied on, so each case reads the input the page hands it and
8
+ * resolves the sections through the page's own `getPageSections`.
9
+ */
10
+ import React from "react";
11
+ import { render, screen } from "@testing-library/react";
12
+ import * as pageRenderer from "@working-theory/page-renderer";
13
+
14
+ jest.mock("next/navigation", () => ({
15
+ __esModule: true,
16
+ notFound: jest.fn(() => {
17
+ throw new Error("NEXT_NOT_FOUND");
18
+ }),
19
+ }));
20
+
21
+ jest.mock("next-mdx-remote/rsc", () => ({
22
+ __esModule: true,
23
+ MDXRemote: ({ source }: { source: string }) =>
24
+ React.createElement("div", { "data-testid": "mdx-rendered" }, source),
25
+ }));
26
+
27
+ import CareersPage from "../page";
28
+
29
+ type Input = Parameters<typeof pageRenderer.renderPage>[0];
30
+ type Section = Awaited<ReturnType<typeof pageRenderer.getPageSections>>[number];
31
+ type GetMdxSource = typeof pageRenderer.getMdxSource;
32
+ type Source = NonNullable<Awaited<ReturnType<GetMdxSource>>>;
33
+ type Frontmatter = Source["frontmatter"];
34
+
35
+ const SEED: Source = {
36
+ frontmatter: {
37
+ type: "CareersList",
38
+ slug: "careers",
39
+ title: "Careers",
40
+ description: "Open roles.",
41
+ },
42
+ body: "",
43
+ };
44
+
45
+ const HOW_WE_WORK: Section = {
46
+ type: "content-block",
47
+ id: "how-we-work",
48
+ data: { headline: "How we work", body: "Async." },
49
+ };
50
+
51
+ const POSTINGS: Record<string, Frontmatter> = {
52
+ writer: {
53
+ type: "JobPosting",
54
+ slug: "writer",
55
+ title: "Writer",
56
+ description: "Write for us.",
57
+ employmentType: "FULL_TIME",
58
+ remote: true,
59
+ location: "Remote (US)",
60
+ publishedAt: "2026-10-01",
61
+ },
62
+ editor: {
63
+ type: "JobPosting",
64
+ slug: "editor",
65
+ title: "Editor",
66
+ description: "Edit for us.",
67
+ employmentType: "PART_TIME",
68
+ location: "Austin, TX",
69
+ publishedAt: "2026-09-01",
70
+ },
71
+ };
72
+
73
+ function sourceFor(postings: Record<string, Frontmatter>) {
74
+ return jest.fn<ReturnType<GetMdxSource>, Parameters<GetMdxSource>>(
75
+ async (type, slug) => {
76
+ if (type === "CareersList") return SEED;
77
+ const frontmatter = postings[slug];
78
+ return frontmatter ? { frontmatter, body: "" } : null;
79
+ },
80
+ );
81
+ }
82
+
83
+ /** Render /careers and return renderPage's input and its resolved sections. */
84
+ async function renderHub(
85
+ slugs: string[],
86
+ postings: Record<string, Frontmatter> = POSTINGS,
87
+ ): Promise<{ input: Input; sections: Section[] }> {
88
+ const spy = jest.spyOn(pageRenderer, "renderPage");
89
+ await CareersPage({
90
+ params: Promise.resolve({ locale: "en" }),
91
+ getMdxSource: sourceFor(postings),
92
+ getMdxSlugs: jest.fn().mockResolvedValue(slugs),
93
+ getPageSections: jest.fn().mockResolvedValue([HOW_WE_WORK]),
94
+ });
95
+ const input = spy.mock.calls[0]![0];
96
+ spy.mockRestore();
97
+ const sections = await input.getPageSections!(SEED.frontmatter);
98
+ return { input, sections };
99
+ }
100
+
101
+ /** The generated section's data, narrowed to the `items` shape. */
102
+ function generated(sections: Section[]) {
103
+ const first = sections[0]!;
104
+ if (first.type !== "items") throw new Error(`expected items, got ${first.type}`);
105
+ return first.data;
106
+ }
107
+
108
+ describe("/careers renders the generated, filterable grid (#8849 D10, AC-4)", () => {
109
+ it("renders in sections mode, the generated section first", async () => {
110
+ const { input, sections } = await renderHub(["editor", "writer"]);
111
+ expect(input.bodyMode).toBeUndefined();
112
+ expect(input.translationsNamespace).toBe("careers");
113
+ expect(sections.map((section) => section.id)).toEqual([
114
+ "open-positions",
115
+ "how-we-work",
116
+ ]);
117
+ expect(sections[0]!.type).toBe("items");
118
+ });
119
+
120
+ it("builds one card per posting, newest first, with the facets from the frontmatter", async () => {
121
+ const { sections } = await renderHub(["editor", "writer"]);
122
+ const data = generated(sections);
123
+ expect(data.items.map((item) => item.title)).toEqual(["Writer", "Editor"]);
124
+ expect(data.items.map((item) => item.eyebrow)).toEqual([
125
+ "Full-time · Remote (US)",
126
+ "Part-time · Austin, TX",
127
+ ]);
128
+ expect(data.items.map((item) => item.link?.href)).toEqual([
129
+ "/careers/writer",
130
+ "/careers/editor",
131
+ ]);
132
+ expect(data.items.map((item) => item.facets)).toEqual([
133
+ { type: "Full-time", location: "Remote (US)" },
134
+ { type: "Part-time", location: "Austin, TX" },
135
+ ]);
136
+ expect(data.filter?.facets?.map((facet) => facet.key)).toEqual([
137
+ "type",
138
+ "location",
139
+ ]);
140
+ expect(data.filter?.countText.other).toBe("{count} open positions");
141
+ });
142
+
143
+ it("reads no facet from schema.mainEntity", async () => {
144
+ const { sections } = await renderHub(["override"], {
145
+ override: {
146
+ type: "JobPosting",
147
+ slug: "override",
148
+ title: "Override",
149
+ description: "Typed only in the override.",
150
+ schema: { mainEntity: { employmentType: "FULL_TIME" } },
151
+ },
152
+ });
153
+ const item = generated(sections).items[0]!;
154
+ expect(item.facets).toBeUndefined();
155
+ expect(item.eyebrow).toBeUndefined();
156
+ });
157
+
158
+ it("with no postings, renders the declared sections alone", async () => {
159
+ const { sections } = await renderHub([]);
160
+ expect(sections.map((section) => section.id)).toEqual(["how-we-work"]);
161
+ });
162
+
163
+ it("with no seed, renders the careers empty state", async () => {
164
+ const element = await CareersPage({
165
+ params: Promise.resolve({ locale: "en" }),
166
+ getMdxSource: jest.fn().mockResolvedValue(null),
167
+ getMdxSlugs: jest.fn().mockResolvedValue(["writer"]),
168
+ });
169
+ render(element);
170
+ expect(screen.getByTestId("section-empty-state")).toBeTruthy();
171
+ });
172
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The careers set end to end (#8849 D7, D8, D9, D10; AC-7): one `writer`
3
+ * posting, served by one shared content mock, through /careers, the posting
4
+ * page and the Free apply page. The `signal-collectors` walk over the same
5
+ * posting is apply-routes.test.tsx's (#8840 D11), which reads the moved
6
+ * `lib/posting-title.ts` through the same page-renderer loader.
7
+ */
8
+ import React from "react";
9
+ import { render, screen } from "@testing-library/react";
10
+ import * as pageRenderer from "@working-theory/page-renderer";
11
+ import { jobApplicationFlowConfig } from "@working-theory/validation";
12
+
13
+ jest.mock("next/navigation", () => ({
14
+ __esModule: true,
15
+ notFound: jest.fn(() => {
16
+ throw new Error("NEXT_NOT_FOUND");
17
+ }),
18
+ }));
19
+
20
+ jest.mock("next-mdx-remote/rsc", () => ({
21
+ __esModule: true,
22
+ MDXRemote: ({ source }: { source: string }) =>
23
+ React.createElement("div", { "data-testid": "mdx-rendered" }, source),
24
+ }));
25
+
26
+ jest.mock("@working-theory/api-client", () => ({
27
+ flows: { get: jest.fn() },
28
+ leads: { captureStatus: jest.fn(), submit: jest.fn() },
29
+ visitorForwardHeaders: () => undefined,
30
+ }));
31
+
32
+ jest.mock("next/headers", () => ({
33
+ __esModule: true,
34
+ headers: jest.fn().mockResolvedValue({ get: () => null }),
35
+ cookies: jest.fn().mockResolvedValue({ get: () => undefined }),
36
+ }));
37
+
38
+ // 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
39
+ jest.mock("../../contact/site-contact-email", () => ({
40
+ readSiteContactEmail: jest.fn().mockReturnValue("jobs@example.com"),
41
+ }));
42
+
43
+ import { flows, leads } from "@working-theory/api-client";
44
+
45
+ import CareersPage from "../page";
46
+ import JobPage from "../[slug]/page";
47
+ import ApplyPage from "../[slug]/[action]/page";
48
+
49
+ const WRITER = {
50
+ type: "JobPosting" as const,
51
+ slug: "writer",
52
+ title: "Writer",
53
+ description: "Write for us.",
54
+ employmentType: "FULL_TIME" as const,
55
+ remote: true,
56
+ location: "Remote (US)",
57
+ };
58
+
59
+ type GetMdxSource = typeof pageRenderer.getMdxSource;
60
+
61
+ const getMdxSource = jest.fn<ReturnType<GetMdxSource>, Parameters<GetMdxSource>>(async (type, slug) => {
62
+ if (type === "CareersList")
63
+ return {
64
+ frontmatter: {
65
+ type: "CareersList" as const,
66
+ slug: "careers",
67
+ title: "Careers",
68
+ description: "Open roles.",
69
+ },
70
+ body: "",
71
+ };
72
+ if (type === "JobPosting" && slug === "writer")
73
+ return { frontmatter: WRITER, body: "The writer role." };
74
+ return null;
75
+ });
76
+ const getMdxSlugs = jest.fn().mockResolvedValue(["writer"]);
77
+
78
+ beforeEach(() => {
79
+ // `lib/posting-title.ts` reads through the package loader, not a prop.
80
+ jest.spyOn(pageRenderer, "getMdxSource").mockImplementation(getMdxSource);
81
+ (flows.get as jest.Mock).mockResolvedValue({
82
+ ok: true,
83
+ status: 200,
84
+ body: jobApplicationFlowConfig,
85
+ });
86
+ (leads.captureStatus as jest.Mock).mockResolvedValue({
87
+ ok: true,
88
+ status: 200,
89
+ body: { ok: true, data: { contact: true, newsletter: true } },
90
+ });
91
+ });
92
+
93
+ afterEach(() => {
94
+ jest.restoreAllMocks();
95
+ });
96
+
97
+ describe("a writer posting renders through the careers set (#8849 AC-7)", () => {
98
+ it("/careers' generated card links to /careers/writer", async () => {
99
+ render(
100
+ await CareersPage({
101
+ params: Promise.resolve({ locale: "en" }),
102
+ getMdxSource,
103
+ getMdxSlugs,
104
+ }),
105
+ );
106
+ const links = Array.from(document.querySelectorAll("a")).map((a) =>
107
+ a.getAttribute("href"),
108
+ );
109
+ expect(links).toContain("/careers/writer");
110
+ expect(screen.getByTestId("items-filter-search")).toBeTruthy();
111
+ });
112
+
113
+ it("/careers/writer renders the posting, its Apply button, and an ApplyAction to the same page", async () => {
114
+ const { container } = render(
115
+ await JobPage({
116
+ params: Promise.resolve({ locale: "en", slug: "writer" }),
117
+ getMdxSource,
118
+ }),
119
+ );
120
+ expect(screen.getByRole("heading", { level: 1, name: "Writer" })).toBeTruthy();
121
+ const cta = screen.getByTestId("job-apply-cta");
122
+ expect(cta.getAttribute("href")).toBe("/careers/writer/apply");
123
+
124
+ const jsonLd = JSON.parse(
125
+ container.querySelector("[data-testid='json-ld']")!.textContent!,
126
+ ) as { "@graph": { "@type": string; mainEntity?: Record<string, unknown> }[] };
127
+ const posting = jsonLd["@graph"].find((node) => node["@type"] === "WebPage")!
128
+ .mainEntity!;
129
+ expect(posting["potentialAction"]).toEqual({
130
+ "@type": "ApplyAction",
131
+ target: "http://localhost:3000/en/careers/writer/apply",
132
+ });
133
+ expect(posting["employmentType"]).toBe("FULL_TIME");
134
+ expect(posting["jobLocationType"]).toBe("TELECOMMUTE");
135
+ });
136
+
137
+ it("the Free page at /careers/writer/apply renders the form bound to writer", async () => {
138
+ render(
139
+ await ApplyPage({
140
+ params: Promise.resolve({ locale: "en", slug: "writer", action: "apply" }),
141
+ }),
142
+ );
143
+ expect(screen.getByRole("heading", { level: 1, name: "Writer" })).toBeTruthy();
144
+ expect(screen.getByTestId("lead-capture-form")).toBeTruthy();
145
+ expect(screen.getByTestId("nav-back-link").getAttribute("href")).toBe(
146
+ "/careers/writer",
147
+ );
148
+ });
149
+ });