create-ailk 0.2.0 → 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 (273) hide show
  1. package/component-catalog.md +50 -53
  2. package/dist/lib/apply-module-patches.d.ts +47 -0
  3. package/dist/lib/apply-module-patches.js +370 -0
  4. package/dist/lib/extract-module-bundle.d.ts +12 -3
  5. package/dist/lib/extract-module-bundle.js +236 -24
  6. package/dist/lib/fetch-module.d.ts +3 -38
  7. package/dist/lib/fetch-module.js +26 -8
  8. package/dist/lib/module-license-gate.d.ts +2 -0
  9. package/dist/lib/module-license-gate.js +1 -0
  10. package/dist/lib/paid-paths.d.ts +16 -0
  11. package/dist/lib/paid-paths.js +90 -0
  12. package/dist/module-architecture.d.ts +139 -23
  13. package/dist/module-architecture.js +668 -25
  14. package/dist/parse-args.d.ts +2 -2
  15. package/dist/parse-args.js +3 -1
  16. package/dist/programmatic.d.ts +2 -1
  17. package/dist/programmatic.js +15 -4
  18. package/dist/surfaces.d.ts +12 -36
  19. package/dist/surfaces.js +35 -5
  20. package/dist/sync-routes.js +31 -1
  21. package/package.json +2 -2
  22. package/templates/.claude/agents/web.md +2 -3
  23. package/templates/.claude/rules/architecture.md +5 -5
  24. package/templates/.claude/skills/README.md +2 -1
  25. package/templates/.env.example +21 -9
  26. package/templates/CONVENTIONS.md +6 -7
  27. package/templates/apps/api/.env.example +9 -0
  28. package/templates/apps/api/CLAUDE.md +33 -5
  29. package/templates/apps/api/package.json +0 -1
  30. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  31. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  32. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  33. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  34. package/templates/apps/api/src/config/index.ts +19 -0
  35. package/templates/apps/api/src/config/modules.ts +12 -14
  36. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  37. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  38. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  39. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  40. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  41. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  42. package/templates/apps/api/src/openapi/index.ts +9 -3
  43. package/templates/apps/api/src/openapi/spec.ts +505 -65
  44. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  45. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  46. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/index.ts +38 -11
  49. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
  50. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
  58. package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
  59. package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
  60. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  61. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  63. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  64. package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
  65. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  66. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  67. package/templates/apps/api/src/server.ts +459 -135
  68. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  69. package/templates/apps/api/src/services/listing-config.ts +58 -0
  70. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  71. package/templates/apps/api/src/services/project-listings.ts +229 -4
  72. package/templates/apps/api/src/vercel-handler.ts +60 -26
  73. package/templates/apps/mcp/.env.example +8 -0
  74. package/templates/apps/mcp/CLAUDE.md +2 -2
  75. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  76. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  77. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  78. package/templates/apps/mcp/src/config/modules.ts +14 -13
  79. package/templates/apps/mcp/src/server.ts +8 -4
  80. package/templates/apps/mcp/src/tools/index.ts +7 -22
  81. package/templates/apps/web/.env.example +15 -0
  82. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  83. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  84. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  91. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  92. package/templates/apps/web/jest.config.cjs +14 -13
  93. package/templates/apps/web/next.config.mjs +1 -3
  94. package/templates/apps/web/package.json +0 -6
  95. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  96. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  97. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  98. package/templates/apps/web/public/favicon-16x16.png +0 -0
  99. package/templates/apps/web/public/favicon-32x32.png +0 -0
  100. package/templates/apps/web/public/favicon.ico +0 -0
  101. package/templates/apps/web/public/favicon.svg +6 -3
  102. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  103. package/templates/apps/web/public/logomark.svg +4 -0
  104. package/templates/apps/web/public/site.webmanifest +2 -2
  105. package/templates/apps/web/public/wordmark.svg +4 -0
  106. package/templates/database/CHANGELOG.md +33 -0
  107. package/templates/database/inbox/schema.prisma +67 -0
  108. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  109. package/templates/database/package.json +1 -1
  110. package/templates/package.json +1 -1
  111. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  112. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  113. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  114. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  115. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  116. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  117. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  118. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  119. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  120. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  121. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  122. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  123. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  124. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  125. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  126. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  127. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  128. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  129. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  130. package/templates/apps/api/src/lib/stripe.ts +0 -52
  131. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  132. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  133. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  134. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  135. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  136. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  137. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  138. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  139. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  140. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  141. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  142. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  143. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  144. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  145. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  146. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  147. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  148. package/templates/apps/api/src/routes/flows/README.md +0 -147
  149. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  150. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  151. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  152. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  153. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  154. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
  155. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  156. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  157. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  158. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  159. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  160. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  161. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  162. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  163. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  164. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  165. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  166. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  167. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  168. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  169. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  170. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  171. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  172. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  173. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  174. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  175. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  176. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  177. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  178. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  179. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  180. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  181. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  182. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  183. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  184. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  185. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  186. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  187. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  188. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  189. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  190. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
  191. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  192. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  193. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  194. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  195. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  196. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  197. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  198. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  199. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  200. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  201. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  202. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  203. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  204. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  205. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  206. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  207. package/templates/apps/api/src/services/flow-engine.ts +0 -1296
  208. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  209. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  210. package/templates/apps/api/src/services/listing-promotion.ts +0 -342
  211. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  212. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  213. package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
  214. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  215. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  216. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  217. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  218. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  219. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  220. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  221. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  222. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  223. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  224. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  225. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  226. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  227. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  228. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  229. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  230. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  231. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  232. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  233. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  234. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  235. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  236. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  237. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  238. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  239. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  240. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  241. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  242. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  243. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  246. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  247. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  248. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  249. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  250. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  251. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  252. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  253. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  254. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  255. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  256. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  257. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  258. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  259. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  260. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  261. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  262. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  263. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  264. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  265. package/templates/apps/web/content/en/blog/README.txt +0 -25
  266. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  267. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  268. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  269. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  270. package/templates/apps/web/lib/source.ts +0 -34
  271. package/templates/apps/web/public/logomark-dark.svg +0 -4
  272. package/templates/apps/web/public/logomark-light.svg +0 -4
  273. 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;