create-ailk 0.1.1 → 0.3.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 (284) hide show
  1. package/component-catalog.md +210 -52
  2. package/dist/cli.js +0 -0
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +12 -3
  6. package/dist/lib/extract-module-bundle.js +236 -24
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/module-license-gate.d.ts +2 -0
  10. package/dist/lib/module-license-gate.js +1 -0
  11. package/dist/lib/paid-paths.d.ts +16 -0
  12. package/dist/lib/paid-paths.js +90 -0
  13. package/dist/module-architecture.d.ts +139 -23
  14. package/dist/module-architecture.js +668 -25
  15. package/dist/parse-args.d.ts +2 -2
  16. package/dist/parse-args.js +3 -1
  17. package/dist/programmatic.d.ts +2 -1
  18. package/dist/programmatic.js +15 -4
  19. package/dist/surfaces.d.ts +12 -36
  20. package/dist/surfaces.js +35 -5
  21. package/dist/sync-routes.js +31 -1
  22. package/package.json +14 -15
  23. package/templates/.claude/agents/web.md +2 -3
  24. package/templates/.claude/rules/architecture.md +5 -5
  25. package/templates/.claude/skills/README.md +2 -1
  26. package/templates/.env.example +21 -9
  27. package/templates/CONVENTIONS.md +6 -7
  28. package/templates/apps/api/.env.example +9 -0
  29. package/templates/apps/api/CLAUDE.md +35 -4
  30. package/templates/apps/api/package.json +0 -1
  31. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  32. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  33. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  34. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  35. package/templates/apps/api/src/config/index.ts +19 -0
  36. package/templates/apps/api/src/config/modules.ts +12 -14
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  38. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  39. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  40. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  41. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  42. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  43. package/templates/apps/api/src/openapi/index.ts +9 -3
  44. package/templates/apps/api/src/openapi/spec.ts +505 -65
  45. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  46. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  49. package/templates/apps/api/src/routes/content/index.ts +38 -11
  50. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
  59. package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
  60. package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
  61. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  63. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  64. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  65. package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
  66. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  67. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  68. package/templates/apps/api/src/server.ts +471 -133
  69. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  70. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  71. package/templates/apps/api/src/services/consent.ts +236 -0
  72. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  73. package/templates/apps/api/src/services/listing-config.ts +58 -0
  74. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  75. package/templates/apps/api/src/services/project-listings.ts +602 -29
  76. package/templates/apps/api/src/vercel-handler.ts +60 -26
  77. package/templates/apps/mcp/.env.example +8 -0
  78. package/templates/apps/mcp/CLAUDE.md +2 -2
  79. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  80. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  81. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  82. package/templates/apps/mcp/src/config/modules.ts +14 -13
  83. package/templates/apps/mcp/src/server.ts +8 -4
  84. package/templates/apps/mcp/src/tools/index.ts +7 -22
  85. package/templates/apps/web/.env.example +15 -0
  86. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  87. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  88. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  90. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  93. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  95. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  96. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  97. package/templates/apps/web/jest.config.cjs +20 -13
  98. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  99. package/templates/apps/web/lib/site-brand.tsx +4 -1
  100. package/templates/apps/web/lib/site-theme.ts +74 -0
  101. package/templates/apps/web/next.config.mjs +1 -3
  102. package/templates/apps/web/package.json +1 -6
  103. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  104. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  105. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  106. package/templates/apps/web/public/favicon-16x16.png +0 -0
  107. package/templates/apps/web/public/favicon-32x32.png +0 -0
  108. package/templates/apps/web/public/favicon.ico +0 -0
  109. package/templates/apps/web/public/favicon.svg +6 -3
  110. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  111. package/templates/apps/web/public/logomark.svg +4 -0
  112. package/templates/apps/web/public/site.webmanifest +2 -2
  113. package/templates/apps/web/public/wordmark.svg +4 -0
  114. package/templates/content/_site.mdx +12 -0
  115. package/templates/database/CHANGELOG.md +94 -0
  116. package/templates/database/inbox/schema.prisma +165 -0
  117. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  118. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  119. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  120. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  121. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/db-generate-locked.sh +0 -0
  124. package/templates/package.json +1 -1
  125. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  126. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  127. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  128. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  129. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  130. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  131. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  132. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  133. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  134. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  135. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  136. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  137. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  138. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  139. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  140. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  141. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  142. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  143. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  144. package/templates/apps/api/src/lib/stripe.ts +0 -52
  145. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  146. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  147. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  148. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  149. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  150. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  151. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  152. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  153. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  154. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  155. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  156. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  157. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  158. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  159. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  160. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  161. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  162. package/templates/apps/api/src/routes/flows/README.md +0 -147
  163. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  164. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  165. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  166. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  167. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  168. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  169. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  170. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  171. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  172. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  173. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  174. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  175. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  176. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  177. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  178. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  179. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  180. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  181. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  182. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  183. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  184. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  185. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  186. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  187. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  188. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  189. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  190. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  191. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  192. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  193. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  194. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  195. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  196. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  197. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  198. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  199. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  200. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  201. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  202. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  203. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  204. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  205. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  206. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  207. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  208. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  209. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  210. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  211. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  212. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  213. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  214. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  215. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  216. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  217. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  218. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  219. package/templates/apps/api/src/services/flow-engine.ts +0 -1278
  220. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  221. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  222. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  223. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  224. package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
  225. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  226. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  227. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  228. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  229. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  230. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  231. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  232. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  233. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  234. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  235. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  236. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  237. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  238. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  239. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  240. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  241. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  242. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  243. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  246. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  247. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  248. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  249. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  250. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  251. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  258. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  259. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  260. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  261. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  262. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  263. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  264. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  265. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  266. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  267. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  268. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  269. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  270. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  271. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  272. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  273. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  274. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  275. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  276. package/templates/apps/web/content/en/blog/README.txt +0 -25
  277. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  278. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  279. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  280. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  281. package/templates/apps/web/lib/source.ts +0 -34
  282. package/templates/apps/web/public/logomark-dark.svg +0 -4
  283. package/templates/apps/web/public/logomark-light.svg +0 -4
  284. package/templates/apps/web/source.config.ts +0 -78
@@ -1,396 +0,0 @@
1
- /**
2
- * Tests for the `/[locale]/flows/[slug]/results/[payload]` cold-load route
3
- * (#4770).
4
- *
5
- * AC3 — a payload encoding a known answer set renders exactly the offer
6
- * names and matched reasons `recommend()` returns for those same
7
- * answers — asserted against the SAME fixture (`FIXTURE_CONFIG` +
8
- * `KNOWN_ANSWERS`) `recommend()` itself would score.
9
- * AC4 — four cases `redirect()` to `/[locale]/flows/[slug]` and render no
10
- * results markup: invalid payload, stale-version payload, a
11
- * flowSlug-mismatched payload, and a non-recommending flow.
12
- *
13
- * `next/navigation`'s `redirect` is mocked to throw (mirrors the sibling
14
- * `thanks/[slug]` route's `notFound` test pattern) so a redirect is
15
- * observable via `.rejects.toThrow` without a real Next.js router. `next-intl`
16
- * is mocked to an identity translator — `ResultsClient`'s only string comes
17
- * from the `flow` namespace's `whyThis` key, which this test does not assert
18
- * on by literal text.
19
- */
20
- import { render, screen } from "@testing-library/react";
21
- import userEvent from "@testing-library/user-event";
22
- import type { FlowConfig } from "@working-theory/validation";
23
- import {
24
- encodePermalink,
25
- PERMALINK_VERSION,
26
- recommend,
27
- resolveLocalizedString,
28
- } from "@working-theory/validation";
29
-
30
- const mockGet = jest.fn();
31
- jest.mock("@working-theory/api-client", () => ({
32
- __esModule: true,
33
- flows: { get: (...args: unknown[]) => mockGet(...args) },
34
- }));
35
-
36
- const mockRedirect = jest.fn((url: string) => {
37
- throw new Error(`NEXT_REDIRECT:${url}`);
38
- });
39
- jest.mock("next/navigation", () => ({
40
- __esModule: true,
41
- redirect: (url: string) => mockRedirect(url),
42
- }));
43
-
44
- jest.mock("next-intl", () => ({
45
- __esModule: true,
46
- useTranslations: () => (key: string) => key,
47
- useMessages: () => ({}),
48
- }));
49
-
50
- import Page, { generateMetadata } from "../page";
51
-
52
- const SLUG = "ailk-recommender";
53
-
54
- const KNOWN_ANSWERS = { team_size: "solo", wants_seo: "yes" };
55
-
56
- function fixtureConfig(overrides: Partial<FlowConfig> = {}): FlowConfig {
57
- return {
58
- slug: SLUG,
59
- defaultLocale: "en",
60
- mode: "recommending",
61
- steps: [
62
- {
63
- key: "team",
64
- type: "segmentation",
65
- copy: { title: { en: "Tell us about your team" } },
66
- questions: [
67
- {
68
- name: "team_size",
69
- options: [
70
- { value: "solo", label: { en: "Solo" } },
71
- { value: "agency", label: { en: "Agency" } },
72
- ],
73
- },
74
- {
75
- name: "wants_seo",
76
- options: [
77
- { value: "yes", label: { en: "Yes" } },
78
- { value: "no", label: { en: "No" } },
79
- ],
80
- },
81
- ],
82
- },
83
- ],
84
- offers: [
85
- {
86
- key: "free_oss",
87
- copy: {
88
- name: { en: "Free" },
89
- description: { en: "Self-serve, open source." },
90
- },
91
- price: { kind: "label", label: { en: "Free" } },
92
- rules: [
93
- {
94
- when: { answer: "team_size", eq: "solo" },
95
- reason: { en: "You're a solo builder." },
96
- },
97
- ],
98
- },
99
- {
100
- key: "seo_addon",
101
- copy: {
102
- name: { en: "SEO Add-on" },
103
- description: { en: "AEO + SEO tuning." },
104
- },
105
- price: { kind: "label", label: { en: "Add-on" } },
106
- rules: [
107
- {
108
- when: { answer: "wants_seo", eq: "yes" },
109
- reason: { en: "You want more organic reach." },
110
- },
111
- ],
112
- },
113
- ],
114
- recommendation: {
115
- key: "recommendation",
116
- copy: { title: { en: "Your recommendation" } },
117
- },
118
- ...overrides,
119
- } as FlowConfig;
120
- }
121
-
122
- beforeEach(() => {
123
- mockGet.mockReset();
124
- mockRedirect.mockClear();
125
- });
126
-
127
- async function expectRedirectTo(
128
- payload: string,
129
- expectedUrl: string,
130
- ): Promise<void> {
131
- await expect(
132
- Page({
133
- params: Promise.resolve({ locale: "en", slug: SLUG, payload }),
134
- }),
135
- ).rejects.toThrow(`NEXT_REDIRECT:${expectedUrl}`);
136
- expect(mockRedirect).toHaveBeenCalledWith(expectedUrl);
137
- }
138
-
139
- describe("AC4 — invalid / stale-version / mismatched / non-recommending all redirect to step 1", () => {
140
- it("redirects on an invalid payload", async () => {
141
- await expectRedirectTo("not-a-valid-payload!!!", `/en/flows/${SLUG}`);
142
- // Never fetched the flow config for an undecodable payload.
143
- expect(mockGet).not.toHaveBeenCalled();
144
- });
145
-
146
- it("redirects on a stale-version payload", async () => {
147
- const stalePayload = encodePermalink({
148
- version: PERMALINK_VERSION - 1,
149
- flowSlug: SLUG,
150
- answers: KNOWN_ANSWERS,
151
- });
152
- await expectRedirectTo(stalePayload, `/en/flows/${SLUG}`);
153
- expect(mockGet).not.toHaveBeenCalled();
154
- });
155
-
156
- it("redirects when the payload's own flowSlug differs from the route's [slug]", async () => {
157
- const mismatchedPayload = encodePermalink({
158
- version: PERMALINK_VERSION,
159
- flowSlug: "a-different-flow",
160
- answers: KNOWN_ANSWERS,
161
- });
162
- await expectRedirectTo(mismatchedPayload, `/en/flows/${SLUG}`);
163
- expect(mockGet).not.toHaveBeenCalled();
164
- });
165
-
166
- it("redirects when the served flow is not a recommending flow (no offers)", async () => {
167
- const payload = encodePermalink({
168
- version: PERMALINK_VERSION,
169
- flowSlug: SLUG,
170
- answers: KNOWN_ANSWERS,
171
- });
172
- const {
173
- offers: _offers,
174
- mode: _mode,
175
- ...nonRecommending
176
- } = fixtureConfig();
177
- mockGet.mockResolvedValueOnce({ ok: true, body: nonRecommending });
178
-
179
- await expectRedirectTo(payload, `/en/flows/${SLUG}`);
180
- });
181
-
182
- it("redirects when the served flow declares mode !== 'recommending'", async () => {
183
- const payload = encodePermalink({
184
- version: PERMALINK_VERSION,
185
- flowSlug: SLUG,
186
- answers: KNOWN_ANSWERS,
187
- });
188
- mockGet.mockResolvedValueOnce({
189
- ok: true,
190
- body: fixtureConfig({ mode: "qualifying" }),
191
- });
192
-
193
- await expectRedirectTo(payload, `/en/flows/${SLUG}`);
194
- });
195
-
196
- it("no results markup renders on any redirect path (renders nothing before throwing)", async () => {
197
- await expect(
198
- Page({
199
- params: Promise.resolve({
200
- locale: "en",
201
- slug: SLUG,
202
- payload: "garbage",
203
- }),
204
- }),
205
- ).rejects.toThrow();
206
- expect(screen.queryByTestId("flow-results")).not.toBeInTheDocument();
207
- });
208
- });
209
-
210
- describe("AC3 — a valid payload against a recommending flow renders exactly recommend()'s fits", () => {
211
- it("renders exactly the offer names and matched reasons recommend() itself returns for the same answers", async () => {
212
- const user = userEvent.setup();
213
- const config = fixtureConfig();
214
- const payload = encodePermalink({
215
- version: PERMALINK_VERSION,
216
- flowSlug: SLUG,
217
- answers: KNOWN_ANSWERS,
218
- });
219
- mockGet.mockResolvedValueOnce({ ok: true, body: config });
220
-
221
- // The SAME fixture (`config` + `KNOWN_ANSWERS`) drives BOTH the mocked
222
- // server response AND this expectation — `recommend()` is the one
223
- // source of truth for "which fits, in what order, with which reason,"
224
- // never a second hand-typed list that could silently drift from it.
225
- const expected = recommend(
226
- {
227
- key: SLUG,
228
- offers: config.offers,
229
- pairings: config.pairings,
230
- rateTable: config.rateTable,
231
- scoring: config.scoring,
232
- },
233
- KNOWN_ANSWERS,
234
- );
235
- expect(expected.fits.length).toBeGreaterThan(0);
236
-
237
- const element = await Page({
238
- params: Promise.resolve({ locale: "en", slug: SLUG, payload }),
239
- });
240
- render(element);
241
-
242
- expect(mockRedirect).not.toHaveBeenCalled();
243
- expect(screen.getByTestId("flow-results")).toBeInTheDocument();
244
- expect(
245
- screen.getByRole("heading", {
246
- name: resolveLocalizedString(
247
- config.recommendation!.copy.title,
248
- "en",
249
- config.defaultLocale,
250
- ),
251
- }),
252
- ).toBeInTheDocument();
253
-
254
- for (const fit of expected.fits) {
255
- const offer = config.offers!.find((o) => o.key === fit.offerKey)!;
256
- const name = resolveLocalizedString(
257
- offer.copy.name,
258
- "en",
259
- config.defaultLocale,
260
- );
261
- // #4842 FR1 — the top fit's name is ALSO the safe-default side panel's
262
- // `offerName`, so the FIRST fit renders exactly twice (panel + card)
263
- // and every other fit exactly once.
264
- expect(screen.getAllByText(name)).toHaveLength(
265
- fit.offerKey === expected.fits[0]!.offerKey ? 2 : 1,
266
- );
267
- if (offer.copy.description) {
268
- expect(
269
- screen.getByText(
270
- resolveLocalizedString(
271
- offer.copy.description,
272
- "en",
273
- config.defaultLocale,
274
- ),
275
- ),
276
- ).toBeInTheDocument();
277
- }
278
-
279
- // The matched reason renders inside the "Why this?" Popover
280
- // disclosure — reveal it (mirrors packages/ui/src/blocks/__tests__/
281
- // RecommendationCard.test.tsx's own click-to-reveal pattern).
282
- await user.click(
283
- screen.getByTestId(`recommendation-card-${fit.offerKey}-why-trigger`),
284
- );
285
- expect(
286
- await screen.findByText(
287
- resolveLocalizedString(fit.reason, "en", config.defaultLocale),
288
- ),
289
- ).toBeInTheDocument();
290
- }
291
- });
292
- });
293
-
294
- describe("generateMetadata", () => {
295
- it("resolves the page title from the compiled recommendation marker", async () => {
296
- const payload = encodePermalink({
297
- version: PERMALINK_VERSION,
298
- flowSlug: SLUG,
299
- answers: KNOWN_ANSWERS,
300
- });
301
- mockGet.mockResolvedValueOnce({ ok: true, body: fixtureConfig() });
302
-
303
- const metadata = await generateMetadata({
304
- params: Promise.resolve({ locale: "en", slug: SLUG, payload }),
305
- });
306
- expect(metadata.title).toBe("Your recommendation");
307
- });
308
-
309
- it("returns an empty metadata object for an invalid payload", async () => {
310
- const metadata = await generateMetadata({
311
- params: Promise.resolve({
312
- locale: "en",
313
- slug: SLUG,
314
- payload: "garbage",
315
- }),
316
- });
317
- expect(metadata).toEqual({});
318
- expect(mockGet).not.toHaveBeenCalled();
319
- });
320
- });
321
-
322
- // ─── #4842 AC-5 — the `?checkout=` return states (additive; the cases above
323
- // are the regression floor and stay as they were) ───────────────────────────
324
-
325
- describe("AC-5 (#4842) — ?checkout= return states render over the same recommendation", () => {
326
- function validPayload(): string {
327
- return encodePermalink({
328
- version: PERMALINK_VERSION,
329
- flowSlug: SLUG,
330
- answers: KNOWN_ANSWERS,
331
- });
332
- }
333
-
334
- async function renderWithSearch(
335
- checkout: string | undefined,
336
- ): Promise<ReturnType<typeof recommend>> {
337
- const config = fixtureConfig();
338
- mockGet.mockResolvedValueOnce({ ok: true, body: config });
339
- const expected = recommend(
340
- {
341
- key: SLUG,
342
- offers: config.offers,
343
- pairings: config.pairings,
344
- rateTable: config.rateTable,
345
- scoring: config.scoring,
346
- },
347
- KNOWN_ANSWERS,
348
- );
349
- const element = await Page({
350
- params: Promise.resolve({ locale: "en", slug: SLUG, payload: validPayload() }),
351
- searchParams: Promise.resolve(checkout === undefined ? {} : { checkout }),
352
- });
353
- render(element);
354
- expect(mockRedirect).not.toHaveBeenCalled();
355
- return expected;
356
- }
357
-
358
- function expectIdenticalFits(expected: ReturnType<typeof recommend>): void {
359
- expect(expected.fits.length).toBeGreaterThan(0);
360
- for (const fit of expected.fits) {
361
- expect(screen.getByTestId(`recommendation-card-${fit.offerKey}`)).toBeInTheDocument();
362
- }
363
- expect(screen.getAllByTestId(/^recommendation-card-[a-z_]+$/)).toHaveLength(
364
- expected.fits.length,
365
- );
366
- }
367
-
368
- it("?checkout=success renders the success status line AND the identical fit cards", async () => {
369
- const expected = await renderWithSearch("success");
370
- expect(screen.getByTestId("flow-results-return-state")).toHaveTextContent("checkoutSuccess");
371
- expect(screen.getByRole("status")).toBeInTheDocument();
372
- expectIdenticalFits(expected);
373
- });
374
-
375
- it("?checkout=cancelled renders the cancelled status line AND the identical fit cards", async () => {
376
- const expected = await renderWithSearch("cancelled");
377
- expect(screen.getByTestId("flow-results-return-state")).toHaveTextContent("checkoutCancelled");
378
- expectIdenticalFits(expected);
379
- });
380
-
381
- it("an unrecognised value renders no status line; the fits are unchanged", async () => {
382
- const expected = await renderWithSearch("bogus");
383
- expect(screen.queryByTestId("flow-results-return-state")).not.toBeInTheDocument();
384
- expectIdenticalFits(expected);
385
- });
386
-
387
- it("a return param never changes which redirect guard fires (invalid payload still redirects to step 1)", async () => {
388
- await expect(
389
- Page({
390
- params: Promise.resolve({ locale: "en", slug: SLUG, payload: "not-a-valid-payload!!!" }),
391
- searchParams: Promise.resolve({ checkout: "success" }),
392
- }),
393
- ).rejects.toThrow(`NEXT_REDIRECT:/en/flows/${SLUG}`);
394
- expect(mockGet).not.toHaveBeenCalled();
395
- });
396
- });
@@ -1,114 +0,0 @@
1
- /**
2
- * /[locale]/flows/[slug]/results/[payload] — the cold-load results route
3
- * (#4770).
4
- *
5
- * Server-fetches the flow config exactly as `flows/[slug]/page.tsx` does
6
- * (`flows.get(slug)`, always dynamically rendered — the config is DB-backed
7
- * live data).
8
- * Decodes the versioned, answers-only permalink (`decodePermalink`,
9
- * `@working-theory/validation`) and `redirect()`s to `/[locale]/flows/[slug]`
10
- * — step 1, never an error page — on ANY of: an invalid payload (FR6), a
11
- * stale-version payload (FR6), a payload whose own `flowSlug` does not match
12
- * this route's `[slug]` (D2/FR6), or a served flow that is not a
13
- * recommending register (D8 — `mode !== "recommending" || !offers`, checked
14
- * against the field `recommend()` is about to be handed rather than
15
- * inferring the compiler's five-keys-travel-together guarantee).
16
- *
17
- * On a valid payload against a recommending flow, renders `ResultsClient`,
18
- * which runs the already-shipped pure `recommend()` in the browser and
19
- * renders the returned fits through the existing `RecommendationCard`
20
- * (D10 — a minimal render; Epic #4759 recomposes it against the LOCKED
21
- * DESIGN.md).
22
- *
23
- * `?checkout=success|cancelled` (#4842 FR7/D6) is read only after every
24
- * guard above and rendered by `ResultsClient` as a one-line status over the
25
- * identical recommendation.
26
- */
27
- import { flows } from "@working-theory/api-client";
28
- import {
29
- decodePermalink,
30
- resolveLocalizedString,
31
- } from "@working-theory/validation";
32
- import type { Metadata } from "next";
33
- import { redirect } from "next/navigation";
34
- import type { ReactElement } from "react";
35
-
36
- import type { ResultsReturnState } from "./ResultsClient";
37
- import { ResultsClient } from "./ResultsClient";
38
-
39
- export const dynamic = "force-dynamic";
40
-
41
- interface Props {
42
- params: Promise<{ locale: string; slug: string; payload: string }>;
43
- /** FR7 (#4842) — `?checkout=success|cancelled` after Stripe's hosted page. */
44
- searchParams?: Promise<Record<string, string | string[] | undefined>>;
45
- }
46
-
47
- /**
48
- * D6 (#4842) — the return micro-state, validated against the two literals;
49
- * anything else (absent, an array, an unknown value) renders no status line.
50
- */
51
- function resolveReturnState(
52
- value: string | string[] | undefined,
53
- ): ResultsReturnState | undefined {
54
- return value === "success" || value === "cancelled" ? value : undefined;
55
- }
56
-
57
- export async function generateMetadata({ params }: Props): Promise<Metadata> {
58
- const { locale, slug, payload } = await params;
59
-
60
- const decoded = decodePermalink(payload);
61
- if (!decoded.ok || decoded.value.flowSlug !== slug) return {};
62
-
63
- const res = await flows.get(slug);
64
- if (!res.ok) return {};
65
-
66
- const { recommendation } = res.body;
67
- if (!recommendation) return {};
68
-
69
- return {
70
- title: resolveLocalizedString(
71
- recommendation.copy.title,
72
- locale,
73
- res.body.defaultLocale,
74
- ),
75
- };
76
- }
77
-
78
- export default async function Page({
79
- params,
80
- searchParams,
81
- }: Props): Promise<ReactElement> {
82
- const { locale, slug, payload } = await params;
83
-
84
- const decoded = decodePermalink(payload);
85
- if (!decoded.ok) {
86
- redirect(`/${locale}/flows/${slug}`);
87
- }
88
- if (decoded.value.flowSlug !== slug) {
89
- redirect(`/${locale}/flows/${slug}`);
90
- }
91
-
92
- const res = await flows.get(slug);
93
- if (!res.ok) {
94
- redirect(`/${locale}/flows/${slug}`);
95
- }
96
- if (res.body.mode !== "recommending" || res.body.offers === undefined) {
97
- redirect(`/${locale}/flows/${slug}`);
98
- }
99
-
100
- // FR7 — read AFTER every redirect guard above, so a return param never
101
- // changes which guard fires or where it sends the visitor.
102
- const returnState = resolveReturnState((await searchParams)?.checkout);
103
-
104
- return (
105
- <ResultsClient
106
- config={res.body}
107
- locale={locale}
108
- answers={decoded.value.answers}
109
- slug={slug}
110
- payload={payload}
111
- {...(returnState !== undefined ? { returnState } : {})}
112
- />
113
- );
114
- }
@@ -1,110 +0,0 @@
1
- "use client";
2
-
3
- /**
4
- * ListingApplicationClient — the REFERENCE MOUNT for the founder application
5
- * (#5048).
6
- *
7
- * This file is the whole per-site cost of the consumer surface, and it is
8
- * deliberately this short. Before it, a site had to hand-write three proxy
9
- * routes, a form that saved on every advance, and a decision about where a
10
- * bearer token lives. What is left is: bind `projectListings` to the section,
11
- * and decide what to do with the token when `start` mints one.
12
- *
13
- * IT IS NOT A BFF, and a site must not copy it into one (the placement
14
- * decision's D4). `apps/api` is called DIRECTLY: an agent-mediated caller
15
- * (#5039) cannot use any site's proxy, so the direct path has to be safe on
16
- * its own terms regardless — and it is, because `siteId` is resolved
17
- * server-side from the public site key rather than taken from the body. A
18
- * proxy would be convenience, not a security boundary.
19
- *
20
- * TOKEN CUSTODY (#5048 (a)). The token is caller-held. This mount reads a
21
- * resume link's `id` and `token` query parameters and passes them straight to
22
- * the section; when `start` mints a new pair it replaces the URL's query
23
- * parameters with them, so a founder who reloads or navigates back keeps their
24
- * draft. That is a deliberate choice with a cost: a token in a URL lands in
25
- * browser history and in any `Referer` the page sends. It is the accepted cost
26
- * of a resume link that has to survive being EMAILED and opened in a cold
27
- * browser (samhenry #316 D6b), which is the constraint that rules out an
28
- * httpOnly cookie entirely. A site with different constraints replaces this
29
- * one function; nothing else here changes.
30
- *
31
- * NO ANTI-AUTOMATION CONTROLS. No honeypot, no timing check, deliberately —
32
- * see #316 D6b and #5039.
33
- *
34
- * SHIPS NO COPY, AND NO ROUTE. Every string comes from the `config` and
35
- * `messages` a caller passes in. There is deliberately no `page.tsx` beside
36
- * this file: a route would need a question set, and a question set is the
37
- * SITE's content — shipping a placeholder one here would put founder-readable
38
- * copy in the template, which is exactly what this unit exists to stop. A site
39
- * adds its own route, imports this wrapper, and passes its own config.
40
- */
41
-
42
- import { projectListings } from "@working-theory/api-client";
43
- import { ListingApplicationFlow } from "@working-theory/ui";
44
- import type {
45
- FlowStepperMessages,
46
- ListingApplicationDraftClient,
47
- ListingApplicationMessages,
48
- } from "@working-theory/ui";
49
- import type { ListingApplicationConfig } from "@working-theory/validation";
50
- import { useRouter, useSearchParams } from "next/navigation";
51
- import type { ReactElement, ReactNode } from "react";
52
-
53
- /**
54
- * `projectListings` satisfies the section's structural client contract as-is —
55
- * `start`, `patchDraft` and `submit` already have the right shapes, and the
56
- * section defines the interface rather than importing this package so that
57
- * `packages/ui` stays transport-agnostic (#3957 D2).
58
- */
59
- const client: ListingApplicationDraftClient = projectListings;
60
-
61
- /** The query parameters an emailed resume link carries. */
62
- const DRAFT_ID_PARAM = "draft";
63
- const RESUME_TOKEN_PARAM = "token";
64
-
65
- interface Props {
66
- config: ListingApplicationConfig;
67
- locale: string;
68
- messages: FlowStepperMessages & ListingApplicationMessages;
69
- /** Rendered under the live tile — the per-listing privacy notice goes here. */
70
- tileFooter?: ReactNode;
71
- }
72
-
73
- export function ListingApplicationClient({
74
- config,
75
- locale,
76
- messages,
77
- tileFooter,
78
- }: Props): ReactElement {
79
- const router = useRouter();
80
- const params = useSearchParams();
81
-
82
- const draftId = params.get(DRAFT_ID_PARAM);
83
- const resumeToken = params.get(RESUME_TOKEN_PARAM);
84
- const resume =
85
- draftId && resumeToken ? { id: draftId, resumeToken } : undefined;
86
-
87
- return (
88
- <ListingApplicationFlow
89
- config={config}
90
- locale={locale}
91
- messages={messages}
92
- client={client}
93
- {...(resume ? { resume } : {})}
94
- {...(tileFooter !== undefined ? { tileFooter } : {})}
95
- onDraftStarted={({ id, resumeToken: token }) => {
96
- // Put the pair in the URL so a reload or a back-navigation resumes
97
- // rather than starting a second draft. `replace`, not `push`: the
98
- // founder should not have to walk back through one history entry per
99
- // step. The site's own email send goes here too — the same two values
100
- // are what its resume link carries.
101
- const next = new URLSearchParams(params.toString());
102
- next.set(DRAFT_ID_PARAM, id);
103
- next.set(RESUME_TOKEN_PARAM, token);
104
- router.replace(`?${next.toString()}`, { scroll: false });
105
- }}
106
- />
107
- );
108
- }
109
-
110
- export default ListingApplicationClient;