create-ailk 0.4.0 → 0.6.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 (396) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +12248 -0
  3. package/component-catalog.md +193 -68
  4. package/dist/cli.js +29 -1
  5. package/dist/derive-architecture.js +5 -2
  6. package/dist/lib/api-boot-assertion.d.ts +46 -0
  7. package/dist/lib/api-boot-assertion.js +70 -0
  8. package/dist/lib/extract-module-bundle.d.ts +50 -2
  9. package/dist/lib/extract-module-bundle.js +223 -15
  10. package/dist/lib/fetch-guard.d.ts +28 -0
  11. package/dist/lib/fetch-guard.js +101 -0
  12. package/dist/lib/fetch-module.js +4 -0
  13. package/dist/lib/fetch-template.js +4 -0
  14. package/dist/lib/import-graph.d.ts +29 -0
  15. package/dist/lib/import-graph.js +140 -0
  16. package/dist/module-architecture.d.ts +60 -8
  17. package/dist/module-architecture.js +587 -242
  18. package/dist/parse-args.js +6 -2
  19. package/dist/programmatic.d.ts +10 -0
  20. package/dist/programmatic.js +134 -19
  21. package/dist/site-chrome.d.ts +80 -0
  22. package/dist/site-chrome.js +135 -0
  23. package/dist/surfaces.d.ts +21 -6
  24. package/dist/surfaces.js +209 -36
  25. package/dist/template-manifest.d.ts +88 -3
  26. package/dist/template-manifest.js +281 -29
  27. package/dist/template-names.d.ts +1 -1
  28. package/dist/template-names.js +2 -0
  29. package/package.json +10 -2
  30. package/site-templates/_base/ailk-template.json +6 -0
  31. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  32. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
  34. package/site-templates/marketing-site/ailk-template.json +11 -0
  35. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  36. package/site-templates/marketing-site/provenance.json +4 -0
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  38. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
  39. package/site-templates/marketing-site-free/ailk-template.json +2 -1
  40. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
  41. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
  42. package/site-templates/minimalism-site/ailk-template.json +22 -0
  43. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  44. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  45. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  46. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  47. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  48. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  49. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  50. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  51. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  52. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  53. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  54. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  55. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  56. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  57. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
  58. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  59. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  60. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  61. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  62. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  63. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  64. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  65. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  66. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  67. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  68. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  69. package/site-templates/minimalism-site/provenance.json +4 -0
  70. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  71. package/site-templates/saas-product/ailk-template.json +16 -0
  72. package/site-templates/saas-product/provenance.json +10 -0
  73. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  74. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  75. package/site-templates/simple-service/ailk-template.json +10 -0
  76. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  77. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  78. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  79. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  80. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  81. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  82. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  83. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  84. package/site-templates/simple-service/provenance.json +4 -0
  85. package/templates/.claude/skills/README.md +16 -25
  86. package/templates/.env.example +102 -12
  87. package/templates/.github/workflows/ci.yml +48 -0
  88. package/templates/README.md +3 -1
  89. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  90. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  91. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  92. package/templates/apps/api/.env.example +13 -0
  93. package/templates/apps/api/CLAUDE.md +29 -2
  94. package/templates/apps/api/eslint.config.mjs +69 -0
  95. package/templates/apps/api/jest.config.cjs +3 -0
  96. package/templates/apps/api/package.json +7 -2
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  98. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
  99. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  100. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  101. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  102. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +10 -1
  103. package/templates/apps/api/src/config/index.ts +62 -0
  104. package/templates/apps/api/src/config/modules.ts +7 -3
  105. package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -2
  106. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  107. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  108. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  109. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  110. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  111. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  112. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  113. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  114. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  115. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  116. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  117. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  118. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  119. package/templates/apps/api/src/lib/site.ts +208 -9
  120. package/templates/apps/api/src/lib/stripe.ts +66 -0
  121. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +521 -0
  122. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
  123. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  124. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  125. package/templates/apps/api/src/openapi/spec.ts +543 -2
  126. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  127. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  128. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  129. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  130. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  131. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  132. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  133. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  134. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  135. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  136. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  137. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  138. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  139. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  140. package/templates/apps/api/src/routes/content/index.ts +13 -73
  141. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  142. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  143. package/templates/apps/api/src/routes/content/search.ts +129 -0
  144. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  145. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  146. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  147. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  148. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  149. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  150. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  151. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  152. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  153. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  154. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  155. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  156. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  157. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  158. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  159. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  160. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  161. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  162. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  163. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  164. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  165. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  166. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  167. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  168. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  169. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  170. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  171. package/templates/apps/api/src/server.ts +521 -55
  172. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  173. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  174. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  175. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  176. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  177. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  178. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  179. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  180. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  181. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  182. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  183. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  184. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  185. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  186. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  187. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  188. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  189. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  190. package/templates/apps/api/src/services/audit-log.ts +668 -0
  191. package/templates/apps/api/src/services/consent.ts +18 -0
  192. package/templates/apps/api/src/services/default-tenant.ts +300 -0
  193. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  194. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  195. package/templates/apps/api/src/services/entitlements.ts +80 -0
  196. package/templates/apps/api/src/services/lead-promotion.ts +160 -0
  197. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  198. package/templates/apps/api/src/services/leads-inbox.ts +284 -0
  199. package/templates/apps/api/src/services/listing-config.ts +27 -9
  200. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  201. package/templates/apps/api/src/services/project-listings.ts +201 -6
  202. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  203. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  204. package/templates/apps/api/src/services/revalidation.ts +173 -0
  205. package/templates/apps/api/src/services/verified-email.ts +71 -0
  206. package/templates/apps/api/src/vercel-handler.ts +75 -35
  207. package/templates/apps/mcp/.env.example +17 -0
  208. package/templates/apps/mcp/CLAUDE.md +58 -25
  209. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  210. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  211. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  212. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  213. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  214. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  215. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  216. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  217. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  218. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  219. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  220. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  221. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  222. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  223. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  224. package/templates/apps/mcp/__tests__/parity.test.ts +12 -9
  225. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  226. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  227. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  228. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  229. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  230. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  231. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  232. package/templates/apps/mcp/eslint.config.mjs +69 -0
  233. package/templates/apps/mcp/jest.config.cjs +13 -0
  234. package/templates/apps/mcp/package.json +8 -2
  235. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  236. package/templates/apps/mcp/src/config/modules.ts +10 -5
  237. package/templates/apps/mcp/src/http-transport.ts +102 -29
  238. package/templates/apps/mcp/src/index.ts +73 -11
  239. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  240. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  241. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  242. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  243. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  244. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  245. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  246. package/templates/apps/mcp/src/server.ts +61 -37
  247. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  248. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  249. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  250. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  251. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  252. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  253. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  254. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  255. package/templates/apps/mcp/src/tools/index.ts +9 -7
  256. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  257. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  258. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  259. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  260. package/templates/apps/web/.env.example +74 -8
  261. package/templates/apps/web/CLAUDE.md +46 -0
  262. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  263. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  264. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  265. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  266. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  267. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  268. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  269. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  270. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  271. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  272. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  273. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  274. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  275. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  276. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  277. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  278. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  279. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  280. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  281. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +67 -14
  282. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
  283. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  284. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  285. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  286. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  287. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  288. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  289. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  290. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  291. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  292. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  293. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  294. package/templates/apps/web/app/[locale]/layout.tsx +308 -65
  295. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  296. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  297. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  298. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  299. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  300. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  301. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  302. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  303. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  304. package/templates/apps/web/auth.ts +407 -8
  305. package/templates/apps/web/components/google-analytics.tsx +18 -1
  306. package/templates/apps/web/eslint.config.mjs +69 -0
  307. package/templates/apps/web/jest.config.cjs +15 -19
  308. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  309. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  310. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  311. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +50 -6
  312. package/templates/apps/web/lib/auth-email.ts +318 -0
  313. package/templates/apps/web/lib/auth-env.ts +85 -0
  314. package/templates/apps/web/lib/auth-routes.ts +55 -0
  315. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  316. package/templates/apps/web/lib/nav-config.ts +27 -7
  317. package/templates/apps/web/lib/server-api.ts +1 -1
  318. package/templates/apps/web/lib/site-chrome.ts +29 -0
  319. package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
  320. package/templates/apps/web/messages/en.json +216 -2
  321. package/templates/apps/web/next.config.mjs +20 -0
  322. package/templates/apps/web/package.json +8 -3
  323. package/templates/apps/web/sentry.client.config.ts +131 -0
  324. package/templates/apps/web/sentry.edge.config.ts +72 -0
  325. package/templates/apps/web/sentry.server.config.ts +72 -0
  326. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  327. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  328. package/templates/apps/web/src/lib/routes.ts +13 -18
  329. package/templates/brand_assets/README.md +24 -0
  330. package/templates/content/en/AboutPage/about.mdx +160 -15
  331. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  332. package/templates/content/en/ContactPage/contact.mdx +90 -13
  333. package/templates/content/en/FAQPage/faq.mdx +62 -22
  334. package/templates/content/en/HomePage/home.mdx +244 -20
  335. package/templates/content/en/LandingPage/blog.mdx +83 -0
  336. package/templates/content/en/LandingPage/features.mdx +103 -0
  337. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  338. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  339. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  340. package/templates/database/CHANGELOG.md +149 -0
  341. package/templates/database/README.md +10 -3
  342. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  343. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  344. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  345. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  346. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  347. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  348. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  349. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  350. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  351. package/templates/database/__tests__/seed.test.ts +1 -1
  352. package/templates/database/__tests__/tenancy.test.ts +141 -0
  353. package/templates/database/config/schema.prisma +79 -2
  354. package/templates/database/content/schema.prisma +87 -11
  355. package/templates/database/eslint.config.mjs +69 -0
  356. package/templates/database/inbox/schema.prisma +452 -9
  357. package/templates/database/jest.config.cjs +5 -3
  358. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  359. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  360. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  361. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  362. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  363. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  364. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  365. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  366. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  367. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  368. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  369. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  370. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  371. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  372. package/templates/database/package.json +7 -3
  373. package/templates/database/seed.ts +1 -1
  374. package/templates/database/tenancy.ts +86 -1
  375. package/templates/database/tsconfig.json +1 -0
  376. package/templates/package.json +1 -14
  377. package/versions.json +52 -0
  378. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
  379. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  380. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  381. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  382. package/templates/apps/api/src/routes/content/create.ts +0 -115
  383. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  384. package/templates/apps/api/src/routes/content/update.ts +0 -112
  385. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  386. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  387. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  388. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  389. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  390. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  391. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  392. /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
  393. /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
  394. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  395. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
  396. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -0,0 +1,58 @@
1
+ "use server";
2
+
3
+ /**
4
+ * account/actions.ts — the update-your-card banner's Server Action (#6038,
5
+ * D5): mints a Stripe billing-portal session on SUBMIT, never on render, and
6
+ * hands the browser straight to it via `redirect()`.
7
+ *
8
+ * The portal URL is never embedded in page data — it is created here, on
9
+ * click, and never rendered into HTML or a client bundle (D5's precise
10
+ * claim: `redirect()` DOES surface the target in the address bar/history/
11
+ * navigation telemetry for a Server Action, which is inherent to any hosted-
12
+ * portal handoff, not something this design avoids).
13
+ *
14
+ * CSRF is already handled: `forwardedCookieInit()` forwards the incoming
15
+ * request's real `Origin` alongside the cookie, and `apps/api`'s
16
+ * `authPreHandler` requires a matching `Origin` on this cookie-authenticated
17
+ * mutating request (D3.1/D5).
18
+ *
19
+ * `redirect()` throws `NEXT_REDIRECT` by design — called OUTSIDE the
20
+ * try/catch (mirrors `checkout.actions.ts`'s idiom) so the catch never
21
+ * swallows the navigation this action exists to perform.
22
+ */
23
+
24
+ import { subscription } from "@working-theory/api-client";
25
+ import { redirect } from "next/navigation";
26
+
27
+ import { forwardedCookieInit } from "../../../../lib/server-api";
28
+
29
+ /** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
30
+ function errorName(err: unknown): string {
31
+ return err instanceof Error ? err.name : "unknown";
32
+ }
33
+
34
+ /**
35
+ * Opens the caller's own Stripe billing-portal session and redirects there.
36
+ * Never returns on success. On a non-2xx or thrown error, logs a PII-safe
37
+ * status/name and redirects back to `/account` unchanged (the banner's
38
+ * action-unavailable posture — there is no error UI slot on this button).
39
+ */
40
+ export async function openBillingPortal(): Promise<void> {
41
+ const init = await forwardedCookieInit();
42
+
43
+ let url: string;
44
+ try {
45
+ const res = await subscription.portalSession(init);
46
+ if (!res.ok) {
47
+ console.error(`[openBillingPortal] status=${res.status}`);
48
+ return;
49
+ }
50
+ url = res.body.url;
51
+ } catch (err) {
52
+ console.error(`[openBillingPortal] errName=${errorName(err)}`);
53
+ return;
54
+ }
55
+
56
+ // Outside the try/catch on purpose — see the file header.
57
+ redirect(url);
58
+ }
@@ -1,37 +1,40 @@
1
1
  /**
2
- * /[locale]/account — the signed-in respondent's own profile, results, and
3
- * purchases (#4984).
2
+ * /[locale]/account — the signed-in respondent's own profile, results,
3
+ * purchases, and subscription status (#4984, #6038).
4
4
  *
5
5
  * RSC. The `(authed)` layout is the gate — it already resolved a session or
6
6
  * rendered `SignInGate`; this page reads the session again ONLY for the
7
7
  * profile fields (name / email / verified). Every data read goes through
8
8
  * `@working-theory/api-client` with the caller's cookie forwarded
9
9
  * (`../../../../lib/server-api`, D9): `waitlistSignups.me(key)` once per key
10
- * in `lib/account-config.ts` (Results) and `flowCheckouts.me()` (Purchases).
11
- * A 404 (no signup / no user row for that key) or a 401/403 (session missing,
12
- * email unverified — the profile row already says so) is skipped, never
13
- * thrown; every other error rethrows to the route error boundary.
10
+ * in `lib/account-config.ts` (Results), `flowCheckouts.me()` (Purchases),
11
+ * and `subscription.me()` (the update-your-card banner, #6038). A 404 (no
12
+ * signup / no user row for that key / commerce module off) or a 401/403
13
+ * (session missing, email unverified — the profile row already says so) is
14
+ * skipped, never thrown; every other error rethrows to the route error
15
+ * boundary.
14
16
  *
15
- * Chrome: `PortalShell` hard-codes its /portal nav and accepts only
16
- * `siteName` + `children`, so this composes `AppShell` + `SidebarNav`
17
- * directly — the same structure PortalShell itself uses — with this page's
18
- * own three in-page anchors. The page renders inside the locale layout
19
- * (which owns the marketing Nav/Footer) — the operator's decision; there is
20
- * no second MarketingShell here.
17
+ * Chrome: this page composes `AppShell` + `SidebarNav` directly, with this
18
+ * page's own three in-page anchors. The page renders inside the locale
19
+ * layout (which owns the marketing Nav/Footer) — the operator's decision;
20
+ * there is no second MarketingShell here.
21
21
  */
22
22
 
23
23
  import {
24
24
  flowCheckouts,
25
+ subscription,
25
26
  waitlistSignups,
26
27
  type FlowCheckoutMeItem,
28
+ type SubscriptionMeResponse,
27
29
  type WaitlistSignupMeResponse,
28
30
  } from "@working-theory/api-client";
29
31
  import { AppShell } from "@working-theory/templates/shells";
30
32
  import { localeHref } from "@working-theory/ui";
31
- import { DescriptionList } from "@working-theory/ui/blocks";
33
+ import { DescriptionList, SubscriptionStatusBanner } from "@working-theory/ui/blocks";
32
34
  import { SidebarNav, type SidebarNavItem } from "@working-theory/ui/chrome";
33
35
  import {
34
36
  Badge,
37
+ Button,
35
38
  EmptyState,
36
39
  Heading,
37
40
  MoneyAmount,
@@ -55,6 +58,7 @@ import { forwardedCookieInit, unwrapApiResult } from "../../../../lib/server-api
55
58
  import { ForbiddenState } from "../waitlist/components/ForbiddenState";
56
59
  import { formatCount, formatDateTime } from "../waitlist/format";
57
60
 
61
+ import { openBillingPortal } from "./actions";
58
62
  import { SignOutButton } from "./sign-out-button";
59
63
 
60
64
  interface Props {
@@ -95,6 +99,19 @@ async function readCheckouts(init: CookieInit): Promise<FlowCheckoutMeItem[]> {
95
99
  return unwrapApiResult(Promise.resolve(res));
96
100
  }
97
101
 
102
+ /**
103
+ * The update-your-card banner's read (#6038). `null` covers both "nothing
104
+ * to show" (401/403/404 — the commerce-disabled-build 404 included, D8) and
105
+ * "no row" (a healthy subscriber, `actionRequired: false`) — the page's
106
+ * render condition below is `sub?.actionRequired`, so both collapse to no
107
+ * banner without a separate branch.
108
+ */
109
+ async function readSubscription(init: CookieInit): Promise<SubscriptionMeResponse | null> {
110
+ const res = await subscription.me(init);
111
+ if (!res.ok && SKIPPED_STATUSES.has(res.status)) return null; // AC-4, the existing constant
112
+ return unwrapApiResult(Promise.resolve(res));
113
+ }
114
+
98
115
  export default async function AccountPage({ params }: Props): Promise<ReactElement> {
99
116
  const { locale } = await params;
100
117
  const t = await getTranslations({ locale, namespace: "account" });
@@ -114,7 +131,11 @@ export default async function AccountPage({ params }: Props): Promise<ReactEleme
114
131
  }
115
132
 
116
133
  const init = await forwardedCookieInit();
117
- const [signups, checkouts] = await Promise.all([readSignups(init), readCheckouts(init)]);
134
+ const [signups, checkouts, sub] = await Promise.all([
135
+ readSignups(init),
136
+ readCheckouts(init),
137
+ readSubscription(init),
138
+ ]);
118
139
 
119
140
  const { name, email, emailVerified } = session.user;
120
141
  const displayName = name || email;
@@ -147,6 +168,38 @@ export default async function AccountPage({ params }: Props): Promise<ReactEleme
147
168
  </Text>
148
169
  </div>
149
170
 
171
+ {sub?.actionRequired ? (
172
+ <SubscriptionStatusBanner
173
+ headline={t("subscription.headline")}
174
+ description={
175
+ sub.status === "canceled" && sub.accessRevokesAt
176
+ ? t("subscription.descriptionCanceled", {
177
+ date: formatDateTime(sub.accessRevokesAt, locale),
178
+ })
179
+ : t("subscription.descriptionPastDue")
180
+ }
181
+ tone={sub.status === "canceled" ? "error" : "warning"}
182
+ action={
183
+ sub.portalAvailable ? (
184
+ <form action={openBillingPortal}>
185
+ {/* variant="unstyled" + text-current/border-current, not variant="secondary" —
186
+ a per-mode-fixed color fails contrast against the banner's warning/error
187
+ fill in dark mode (design-review F4); size="big" for the 44px touch-target
188
+ floor this is a mobile CTA under (design-review F5, Rule 14). */}
189
+ <Button
190
+ type="submit"
191
+ variant="unstyled"
192
+ size="big"
193
+ className="rounded-control border-thin border-current bg-transparent px-control-x text-current hover:opacity-90 active:opacity-80"
194
+ >
195
+ {t("subscription.action")}
196
+ </Button>
197
+ </form>
198
+ ) : undefined
199
+ }
200
+ />
201
+ ) : null}
202
+
150
203
  <section id="profile" className="flex flex-col gap-4" data-testid="account-profile">
151
204
  <Heading level={2}>{t("profile.heading")}</Heading>
152
205
  <DescriptionList
@@ -2,7 +2,7 @@
2
2
  * (authed) segment layout — the operator-dashboard session guard (#4665 D9).
3
3
  *
4
4
  * RSC: reads the active Better Auth session via `auth.api.getSession`
5
- * (mirrors `apps-paid/workspace/app/(authed)/layout.tsx`). With no session it
5
+ * (mirrors `apps-paid/admin/app/(authed)/layout.tsx`). With no session it
6
6
  * renders `SignInGate` — the dashboard's own front door — rather than
7
7
  * redirecting (a `redirect("/sign-in")` would render the visitor's requested
8
8
  * path only as a query param; rendering the gate in place keeps the URL, and
@@ -50,5 +50,22 @@ export default async function AuthedLayout({
50
50
  );
51
51
  }
52
52
 
53
- return <>{children}</>;
53
+ // data-profile="app" puts the Pro-host editor surfaces in this group on
54
+ // @working-theory/theme's app profile (#5953); tokens.css's own
55
+ // [data-profile="app"] header documents what it overrides. The div carries
56
+ // no className and no layout of its own — it exists only because this
57
+ // layout returns a fragment and the attribute needs an element.
58
+ //
59
+ // Two things about the placement are deliberate. Only the SIGNED-IN branch
60
+ // carries it: SignInGate above is the marketing site's sign-in front door,
61
+ // not an app surface. And the attribute goes on this nested div rather than
62
+ // the app's <html> because apps/web's marketing routes share that root and
63
+ // must stay on the marketing profile — which also makes this half immune to
64
+ // the `:root` shadowing the Admin host hit (different element, no
65
+ // specificity tie, so the app values apply and inherit down).
66
+ return (
67
+ <div data-profile="app" data-testid="authed-profile-root">
68
+ {children}
69
+ </div>
70
+ );
54
71
  }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Tests for the signed-in owner's lead inbox (#5678 Deliverable 2).
3
+ *
4
+ * Covers:
5
+ * 1. Renders one row per lead, with a `null` name shown as the
6
+ * "unavailable" sentinel rather than blank.
7
+ * 2. Renders the forward-only "next" link when `nextCursor` is present, and
8
+ * omits it when it is `null`.
9
+ * 3. Forwards `?cursor=` from `searchParams` to the api-client call.
10
+ * 4. Renders an EmptyState — not a headerless table — for an empty page.
11
+ * 5. Renders ForbiddenState on a 401/403; falls back to the first page
12
+ * when a supplied `?cursor=` 400s; rethrows every other error.
13
+ */
14
+ import { render, screen } from "@testing-library/react";
15
+
16
+ jest.mock("next-intl/server", () => ({
17
+ __esModule: true,
18
+ getTranslations: jest.fn().mockImplementation(async () => (key: string, values?: Record<string, unknown>) =>
19
+ values ? `copy:${key}:${JSON.stringify(values)}` : `copy:${key}`,
20
+ ),
21
+ }));
22
+
23
+ jest.mock("next/headers", () => ({
24
+ __esModule: true,
25
+ headers: jest.fn().mockResolvedValue(new Headers()),
26
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
27
+ }));
28
+
29
+ const mockList = jest.fn();
30
+ jest.mock("@working-theory/api-client", () => ({
31
+ __esModule: true,
32
+ leads: { list: (...args: unknown[]) => mockList(...args) },
33
+ }));
34
+
35
+ import Page from "../page";
36
+
37
+ const FIXTURE_PAGE = {
38
+ items: [
39
+ {
40
+ id: "lead_1",
41
+ name: "Ada Lovelace",
42
+ email: "ada@example.com",
43
+ source: "web",
44
+ createdAt: "2026-09-01T10:00:00.000Z",
45
+ },
46
+ {
47
+ id: "lead_2",
48
+ name: null,
49
+ email: "anon@example.com",
50
+ source: "conversational-intake",
51
+ createdAt: "2026-08-31T09:00:00.000Z",
52
+ },
53
+ ],
54
+ nextCursor: null as string | null,
55
+ };
56
+
57
+ function renderPage(cursor?: string) {
58
+ return Page({
59
+ params: Promise.resolve({ locale: "en" }),
60
+ searchParams: Promise.resolve(cursor ? { cursor } : {}),
61
+ });
62
+ }
63
+
64
+ describe("LeadsInboxPage", () => {
65
+ beforeEach(() => {
66
+ mockList.mockReset();
67
+ });
68
+
69
+ it("renders one row per lead, with email and source", async () => {
70
+ mockList.mockResolvedValue({ ok: true, body: FIXTURE_PAGE });
71
+ render(await renderPage());
72
+
73
+ expect(screen.getByTestId("leads-inbox-page")).toBeInTheDocument();
74
+ const first = screen.getByTestId("leads-table-row-lead_1");
75
+ expect(first).toHaveTextContent("Ada Lovelace");
76
+ expect(first).toHaveTextContent("ada@example.com");
77
+ expect(first).toHaveTextContent("web");
78
+ expect(screen.getByTestId("leads-table-row-lead_2")).toHaveTextContent(
79
+ "conversational-intake",
80
+ );
81
+ expect(screen.getAllByTestId(/^leads-table-row-/)).toHaveLength(2);
82
+ });
83
+
84
+ it("renders a null name as the unavailable sentinel", async () => {
85
+ mockList.mockResolvedValue({ ok: true, body: FIXTURE_PAGE });
86
+ render(await renderPage());
87
+
88
+ expect(screen.getByTestId("leads-table-row-lead_2")).toHaveTextContent(
89
+ "copy:table.unavailable",
90
+ );
91
+ });
92
+
93
+ it("omits the next link when nextCursor is null", async () => {
94
+ mockList.mockResolvedValue({ ok: true, body: FIXTURE_PAGE });
95
+ render(await renderPage());
96
+
97
+ expect(screen.queryByTestId("leads-table-next")).not.toBeInTheDocument();
98
+ });
99
+
100
+ it("renders the next link pointing at the next cursor", async () => {
101
+ mockList.mockResolvedValue({
102
+ ok: true,
103
+ body: { ...FIXTURE_PAGE, nextCursor: "lead_2" },
104
+ });
105
+ render(await renderPage());
106
+
107
+ const next = screen.getByTestId("leads-table-next");
108
+ expect(next).toBeInTheDocument();
109
+ expect(next).toHaveAttribute("href", "/en/leads?cursor=lead_2");
110
+ });
111
+
112
+ it("forwards ?cursor= to the api-client call", async () => {
113
+ mockList.mockResolvedValue({ ok: true, body: FIXTURE_PAGE });
114
+ render(await renderPage("lead_9"));
115
+
116
+ expect(mockList).toHaveBeenCalledWith(
117
+ { cursor: "lead_9" },
118
+ expect.objectContaining({ headers: expect.objectContaining({ cookie: "session=abc" }) }),
119
+ );
120
+ });
121
+
122
+ it("renders an EmptyState when the owner has no leads", async () => {
123
+ mockList.mockResolvedValue({ ok: true, body: { items: [], nextCursor: null } });
124
+ render(await renderPage());
125
+
126
+ expect(screen.getByTestId("leads-table-empty")).toBeInTheDocument();
127
+ expect(screen.queryByTestId("leads-table")).not.toBeInTheDocument();
128
+ });
129
+
130
+ it("renders ForbiddenState on a 401 rather than throwing", async () => {
131
+ mockList.mockResolvedValue({
132
+ ok: false,
133
+ status: 401,
134
+ body: { error: { code: "unauthorized", message: "Unauthorized" } },
135
+ });
136
+ render(await renderPage());
137
+
138
+ expect(screen.getByTestId("waitlist-forbidden")).toBeInTheDocument();
139
+ });
140
+
141
+ it("rethrows a non-401/403 error", async () => {
142
+ mockList.mockResolvedValue({
143
+ ok: false,
144
+ status: 500,
145
+ body: { error: { code: "internal_error", message: "Boom" } },
146
+ });
147
+ await expect(renderPage()).rejects.toThrow();
148
+ });
149
+
150
+ // PR #5705 security review, finding 3.
151
+ it("falls back to the first page when a supplied ?cursor= 400s", async () => {
152
+ mockList
153
+ .mockResolvedValueOnce({
154
+ ok: false,
155
+ status: 400,
156
+ body: {
157
+ error: {
158
+ code: "validation_failed",
159
+ message: "cursor does not resolve within this scope",
160
+ },
161
+ },
162
+ })
163
+ .mockResolvedValueOnce({ ok: true, body: FIXTURE_PAGE });
164
+
165
+ render(await renderPage("lead_gone"));
166
+
167
+ expect(screen.getByTestId("leads-table")).toBeInTheDocument();
168
+ expect(mockList).toHaveBeenNthCalledWith(1, { cursor: "lead_gone" }, expect.anything());
169
+ expect(mockList).toHaveBeenNthCalledWith(2, {}, expect.anything());
170
+ });
171
+
172
+ it("rethrows a 400 on the FIRST page — there is no cursor to blame", async () => {
173
+ mockList.mockResolvedValue({
174
+ ok: false,
175
+ status: 400,
176
+ body: { error: { code: "validation_failed", message: "Invalid query parameters" } },
177
+ });
178
+ await expect(renderPage()).rejects.toThrow();
179
+ expect(mockList).toHaveBeenCalledTimes(1);
180
+ });
181
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * LeadsTable — the lead inbox's paginated table (#5678 Deliverable 2).
3
+ *
4
+ * App-local composition: `Table` primitive + `EmptyState` when there are no
5
+ * rows + a single forward-only "next page" link. Every string is
6
+ * caller-supplied (translatable) and every value arrives pre-formatted — this
7
+ * component formats nothing itself (Rule 10: formatting happens in the page,
8
+ * via `../../waitlist/format.js`).
9
+ *
10
+ * Deliberately NOT an import of the structurally identical
11
+ * `../../waitlist/components/SignupsTable.tsx`, despite sharing its shape:
12
+ * that file is an `ownedFiles` entry of a PAID module slice
13
+ * (`packages/create-ailk/src/module-architecture.ts` — the
14
+ * `apps/web/app/[locale]/(authed)/waitlist/**` block, which belongs to the
15
+ * `multi-tenant` slice, not `signal-collectors` as the card's brief had it;
16
+ * verified at HEAD, the `SignupsTable.tsx` entry is on line 1035 inside
17
+ * `MODULE_SLICES["multi-tenant"].ownedFiles`). A scaffold built without that
18
+ * module has no such file. Lead capture — and therefore this inbox — ships at
19
+ * every level including Free, so importing it would make a base-tier page
20
+ * depend on a file the strip removes. The pattern is reused; the file is not.
21
+ *
22
+ * `EmptyState`-when-empty follows `ExperimentsTable`'s idiom rather than
23
+ * `SignupsTable`'s (which always renders a table): a headerless table is not
24
+ * an acceptable empty state for a page whose whole body is this one table.
25
+ *
26
+ * `ForbiddenState` and `format.ts`, by contrast, are safe to import: both are
27
+ * deliberately EXCLUDED from that same `ownedFiles` list (see the module
28
+ * file's own ESCALATION comment above the list — they are shared with the
29
+ * Free `(authed)/account` page, which already imports both), so both survive
30
+ * in a build without the paid slice.
31
+ */
32
+
33
+ import type { LeadInboxRow } from "@working-theory/api-client";
34
+ import {
35
+ EmptyState,
36
+ Link,
37
+ Table,
38
+ TableBody,
39
+ TableCaption,
40
+ TableCell,
41
+ TableHead,
42
+ TableHeader,
43
+ TableRow,
44
+ } from "@working-theory/ui/primitives";
45
+ import type { ReactElement } from "react";
46
+
47
+ export interface LeadsTableLabels {
48
+ caption: string;
49
+ columnName: string;
50
+ columnEmail: string;
51
+ columnSource: string;
52
+ columnCreated: string;
53
+ unavailable: string;
54
+ next: string;
55
+ emptyHeadline: string;
56
+ emptyDescription: string;
57
+ }
58
+
59
+ export interface LeadsTableProps {
60
+ locale: string;
61
+ /**
62
+ * `created` is pre-formatted by the caller (Rule 10 — this component never
63
+ * formats a value itself). `lead.name` is `null` when the capture form took
64
+ * no name; it renders `labels.unavailable`, the same sentinel handling the
65
+ * caller's own `format.ts` uses elsewhere on the authed surface.
66
+ *
67
+ * There is no page/URL column: the `Lead` record persists no page or
68
+ * referrer field, so `source` is the only capture-origin signal.
69
+ */
70
+ rows: Array<{
71
+ lead: LeadInboxRow;
72
+ created: string;
73
+ }>;
74
+ nextCursor: string | null;
75
+ labels: LeadsTableLabels;
76
+ }
77
+
78
+ function buildNextHref(locale: string, nextCursor: string): string {
79
+ const params = new URLSearchParams();
80
+ params.set("cursor", nextCursor);
81
+ return `/${locale}/leads?${params.toString()}`;
82
+ }
83
+
84
+ export function LeadsTable({
85
+ locale,
86
+ rows,
87
+ nextCursor,
88
+ labels,
89
+ }: LeadsTableProps): ReactElement {
90
+ if (rows.length === 0) {
91
+ return (
92
+ <div data-testid="leads-table-empty">
93
+ <EmptyState headline={labels.emptyHeadline} description={labels.emptyDescription} />
94
+ </div>
95
+ );
96
+ }
97
+
98
+ return (
99
+ <div data-testid="leads-table-wrapper">
100
+ <Table data-testid="leads-table">
101
+ <TableCaption>{labels.caption}</TableCaption>
102
+ <TableHeader>
103
+ <TableRow>
104
+ <TableHead scope="col">{labels.columnName}</TableHead>
105
+ <TableHead scope="col">{labels.columnEmail}</TableHead>
106
+ <TableHead scope="col">{labels.columnSource}</TableHead>
107
+ <TableHead scope="col">{labels.columnCreated}</TableHead>
108
+ </TableRow>
109
+ </TableHeader>
110
+ <TableBody>
111
+ {rows.map(({ lead, created }) => (
112
+ <TableRow key={lead.id} data-testid={`leads-table-row-${lead.id}`}>
113
+ <TableCell>{lead.name ?? labels.unavailable}</TableCell>
114
+ <TableCell>{lead.email}</TableCell>
115
+ <TableCell>{lead.source}</TableCell>
116
+ <TableCell>{created}</TableCell>
117
+ </TableRow>
118
+ ))}
119
+ </TableBody>
120
+ </Table>
121
+ {nextCursor !== null ? (
122
+ <div className="mt-3 flex justify-end">
123
+ <Link
124
+ href={buildNextHref(locale, nextCursor)}
125
+ data-testid="leads-table-next"
126
+ // 24px hit-area floor (Rule 14) — a bare inline link's
127
+ // line-height box falls short on its own (#4665, caught on the
128
+ // worker's own re-inspection of the render evidence).
129
+ className="inline-flex min-h-6 items-center px-2"
130
+ >
131
+ {labels.next}
132
+ </Link>
133
+ </div>
134
+ ) : null}
135
+ </div>
136
+ );
137
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * /[locale]/leads — the signed-in site owner's lead inbox (#5678).
3
+ *
4
+ * RSC: reads `GET /v1/leads` through `@working-theory/api-client`'s `leads`
5
+ * namespace, forwarding the caller's cookie session
6
+ * (`../../../../lib/server-api`, #4665 D9) — never a hand-rolled `fetch`
7
+ * (`architecture.yaml`: `apps/web` may not reach `apps/api` directly). The
8
+ * route is owner-scoped server-side, so this page passes no site or workspace
9
+ * argument; the only query it owns is the forward-only `?cursor=`.
10
+ *
11
+ * A 401/403 renders `ForbiddenState` (no leak of why — the same shape the
12
+ * waitlist comparison page uses); every other error (a 400 on a malformed
13
+ * cursor, a 500) rethrows to the route's error boundary rather than being
14
+ * swallowed.
15
+ */
16
+
17
+ import { leads, type LeadInboxPage } from "@working-theory/api-client";
18
+ import { PageHeader } from "@working-theory/ui/blocks";
19
+ import { Container, Heading, Text } from "@working-theory/ui/primitives";
20
+ import type { Metadata } from "next";
21
+ import { getTranslations } from "next-intl/server";
22
+ import type { ReactElement } from "react";
23
+
24
+ import { ApiError, forwardedCookieInit, unwrapApiResult } from "../../../../lib/server-api";
25
+ import { ForbiddenState } from "../waitlist/components/ForbiddenState";
26
+ import { formatDateTime } from "../waitlist/format";
27
+
28
+ import { LeadsTable } from "./components/LeadsTable";
29
+
30
+ interface Props {
31
+ params: Promise<{ locale: string }>;
32
+ searchParams: Promise<{ cursor?: string }>;
33
+ }
34
+
35
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
36
+ const { locale } = await params;
37
+ const t = await getTranslations({ locale, namespace: "leadsInbox" });
38
+ return { title: t("title"), description: t("description") };
39
+ }
40
+
41
+ export default async function LeadsInboxPage({
42
+ params,
43
+ searchParams,
44
+ }: Props): Promise<ReactElement> {
45
+ const { locale } = await params;
46
+ const { cursor } = await searchParams;
47
+ const t = await getTranslations({ locale, namespace: "leadsInbox" });
48
+
49
+ const init = await forwardedCookieInit();
50
+
51
+ let page: LeadInboxPage;
52
+ try {
53
+ page = await unwrapApiResult(leads.list(cursor ? { cursor } : {}, init));
54
+ } catch (err) {
55
+ if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
56
+ return (
57
+ <ForbiddenState
58
+ headline={t("forbiddenHeadline")}
59
+ description={t("forbiddenDescription")}
60
+ />
61
+ );
62
+ }
63
+ // A 400 means the `?cursor=` in the URL did not resolve — it was typed,
64
+ // or it is a stale next-page link whose row has since gone. That is a bad
65
+ // address, not a broken page, so fall back to the first page rather than
66
+ // letting it reach the route's error boundary (PR #5705 security review,
67
+ // finding 3). A 400 from the FIRST-page read has no cursor to blame and
68
+ // would be a genuine contract break, so it is rethrown.
69
+ if (err instanceof ApiError && err.status === 400 && cursor) {
70
+ page = await unwrapApiResult(leads.list({}, init));
71
+ } else {
72
+ throw err;
73
+ }
74
+ }
75
+
76
+ const rows = page.items.map((lead) => ({
77
+ lead,
78
+ created: formatDateTime(lead.createdAt, locale),
79
+ }));
80
+
81
+ return (
82
+ <main className="min-h-screen bg-surface text-fg" data-testid="leads-inbox-page">
83
+ <PageHeader />
84
+ <Container className="flex flex-col gap-6 py-section">
85
+ <div>
86
+ <Heading level={1}>{t("title")}</Heading>
87
+ <Text as="p" variant="body-sm" tone="muted" className="mt-1">
88
+ {t("description")}
89
+ </Text>
90
+ </div>
91
+
92
+ <LeadsTable
93
+ locale={locale}
94
+ rows={rows}
95
+ nextCursor={page.nextCursor}
96
+ labels={{
97
+ caption: t("table.caption"),
98
+ columnName: t("table.columnName"),
99
+ columnEmail: t("table.columnEmail"),
100
+ columnSource: t("table.columnSource"),
101
+ columnCreated: t("table.columnCreated"),
102
+ unavailable: t("table.unavailable"),
103
+ next: t("table.next"),
104
+ emptyHeadline: t("table.emptyHeadline"),
105
+ emptyDescription: t("table.emptyDescription"),
106
+ }}
107
+ />
108
+ </Container>
109
+ </main>
110
+ );
111
+ }
@@ -4,7 +4,7 @@
4
4
  * SignInGate — the `(authed)` group's own front door (#4665 D9).
5
5
  *
6
6
  * This gate composes the shipped `AuthPanel` section directly (so the
7
- * dashboard is reachable in place, unlike `apps-paid/workspace`, which redirects
7
+ * dashboard is reachable in place, unlike `apps-paid/admin`, which redirects
8
8
  * to "/" on no session) and ALSO links to `apps/web`'s own `/sign-in` page
9
9
  * (#4984) carrying `?next=<pathname>`, so a visitor who prefers the full
10
10
  * sign-in surface lands back on the exact page they requested.