create-ailk 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (342) hide show
  1. package/component-catalog.json +186 -8
  2. package/component-catalog.md +9 -7
  3. package/dist/add-module.js +20 -14
  4. package/dist/add-route.d.ts +55 -1
  5. package/dist/add-route.js +294 -12
  6. package/dist/cli.js +27 -10
  7. package/dist/derive-architecture.d.ts +12 -3
  8. package/dist/derive-architecture.js +20 -8
  9. package/dist/design-package.js +7 -2
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +5 -5
  12. package/dist/lib/npm-auth.d.ts +9 -0
  13. package/dist/lib/npm-auth.js +48 -0
  14. package/dist/lib/scaffold-render-sweep.d.ts +3 -2
  15. package/dist/lib/scaffold-render-sweep.js +5 -8
  16. package/dist/module-architecture.js +99 -9
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/programmatic.js +19 -5
  20. package/dist/seed-flows.d.ts +4 -1
  21. package/dist/seed-flows.js +50 -4
  22. package/dist/site-chrome.d.ts +8 -0
  23. package/dist/site-chrome.js +17 -1
  24. package/dist/spoke-checkout.d.ts +65 -41
  25. package/dist/spoke-checkout.js +208 -430
  26. package/dist/starter-grid.d.ts +0 -3
  27. package/dist/starter-grid.js +24 -14
  28. package/dist/surfaces.js +52 -26
  29. package/dist/sync-routes.d.ts +38 -1
  30. package/dist/sync-routes.js +187 -13
  31. package/dist/template-record.d.ts +4 -3
  32. package/dist/template-record.js +8 -4
  33. package/dist/topology.js +25 -1
  34. package/dist/update.js +15 -0
  35. package/package.json +3 -3
  36. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  37. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  38. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  39. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  41. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  43. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  46. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  48. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  49. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  50. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  51. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  52. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  53. package/templates/.env.example +9 -7
  54. package/templates/.github/workflows/ci.yml +13 -0
  55. package/templates/README.md +1 -0
  56. package/templates/_gitignore +4 -0
  57. package/templates/apps/api/.env.example +27 -0
  58. package/templates/apps/api/CLAUDE.md +87 -17
  59. package/templates/apps/api/package.json +1 -0
  60. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  61. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  62. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  63. package/templates/apps/api/src/config/index.ts +109 -0
  64. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  65. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  66. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  67. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  68. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  69. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  70. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  71. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  72. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  73. package/templates/apps/api/src/lib/stripe.ts +74 -4
  74. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  75. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  76. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  77. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  78. package/templates/apps/api/src/middleware/auth.ts +13 -6
  79. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  80. package/templates/apps/api/src/openapi/spec.ts +33 -3
  81. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  82. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  83. package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
  84. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  85. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
  86. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  87. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  88. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  89. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  90. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  91. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  92. package/templates/apps/api/src/routes/consent/account.ts +268 -0
  93. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  94. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  95. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  96. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  97. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  98. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  99. package/templates/apps/api/src/routes/content/index.ts +16 -2
  100. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  101. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  102. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  103. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  104. package/templates/apps/api/src/routes/leads/index.ts +164 -7
  105. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  106. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  107. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  108. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  109. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
  110. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  111. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  112. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  113. package/templates/apps/api/src/routes/webhooks/stripe.ts +245 -36
  114. package/templates/apps/api/src/server.ts +163 -46
  115. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  116. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
  117. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  118. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1383 -133
  119. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  120. package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
  121. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
  122. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  123. package/templates/apps/api/src/services/consent.ts +95 -0
  124. package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
  125. package/templates/apps/api/src/services/entitlements.ts +157 -1
  126. package/templates/apps/api/src/services/lead-routing.ts +431 -47
  127. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  128. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
  129. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  130. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
  131. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  132. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
  133. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
  134. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  135. package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
  136. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
  137. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  138. package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
  139. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  140. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  141. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  142. package/templates/apps/api/src/services/person.ts +164 -17
  143. package/templates/apps/api/src/services/plan-offerings.ts +125 -0
  144. package/templates/apps/api/src/services/public-site-resolver.ts +27 -15
  145. package/templates/apps/mcp/CLAUDE.md +22 -14
  146. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  147. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  148. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  149. package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
  150. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  151. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  152. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  153. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  154. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  155. package/templates/apps/mcp/src/server.ts +5 -0
  156. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  157. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  158. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  159. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  160. package/templates/apps/web/.env.example +24 -31
  161. package/templates/apps/web/CLAUDE.md +393 -9
  162. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  163. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  164. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  165. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  166. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  167. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  168. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  169. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  170. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  171. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  172. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  173. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  174. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  175. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  176. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  177. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  178. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  179. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  183. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  184. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  185. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  186. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  187. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  188. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  189. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  190. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  191. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  192. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  193. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  194. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  195. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  196. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +295 -0
  197. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +120 -0
  198. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +23 -0
  199. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +319 -0
  200. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  201. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  202. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +141 -0
  203. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +424 -0
  204. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +72 -11
  205. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  206. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  207. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  208. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  209. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  210. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  211. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  212. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
  213. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  214. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +160 -13
  215. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +466 -36
  216. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
  217. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +172 -57
  218. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  219. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -60
  220. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  221. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  222. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +341 -4
  223. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  224. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  225. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  226. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  227. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  228. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  229. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  230. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  231. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
  232. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  233. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  234. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  235. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  236. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  237. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  238. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  239. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  240. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  241. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  242. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  243. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  244. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  245. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  246. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  247. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  248. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  249. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  250. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  251. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  252. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  253. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  254. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  255. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  256. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  257. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  258. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  259. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  260. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  261. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  262. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  263. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  264. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  265. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  266. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  267. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  268. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  269. package/templates/apps/web/auth.ts +28 -2
  270. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  271. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  272. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  273. package/templates/apps/web/lib/__tests__/deck-page.test.ts +7 -1
  274. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  275. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
  276. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  277. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  278. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +101 -25
  279. package/templates/apps/web/lib/account-invite.ts +37 -0
  280. package/templates/apps/web/lib/auth-routes.ts +36 -2
  281. package/templates/apps/web/lib/checkout-config.ts +16 -0
  282. package/templates/apps/web/lib/consent-policies.ts +22 -0
  283. package/templates/apps/web/lib/deck-page.ts +7 -0
  284. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  285. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  286. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  287. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  288. package/templates/apps/web/lib/magnet-routes.ts +42 -0
  289. package/templates/apps/web/lib/modules.generated.ts +9 -0
  290. package/templates/apps/web/lib/newsletter.actions.ts +202 -0
  291. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  292. package/templates/apps/web/lib/posting-title.ts +17 -0
  293. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  294. package/templates/apps/web/lib/sign-up-agreements.ts +449 -0
  295. package/templates/apps/web/lib/site-chrome.ts +2 -0
  296. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  297. package/templates/apps/web/messages/en.json +71 -20
  298. package/templates/apps/web/middleware.ts +12 -2
  299. package/templates/apps/web/package.json +1 -0
  300. package/templates/apps/web/security-headers.config.ts +17 -0
  301. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  302. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  303. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  304. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  305. package/templates/content/en/BlogList/blog.mdx +4 -71
  306. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  307. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  308. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  309. package/templates/content/en/BookCall/start-project.mdx +1 -17
  310. package/templates/content/en/ContactPage/contact.mdx +6 -63
  311. package/templates/content/en/HomePage/home.mdx +3 -21
  312. package/templates/database/CHANGELOG.md +69 -0
  313. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  314. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  315. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  316. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  317. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  318. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  319. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  320. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  321. package/templates/database/__tests__/seed.test.ts +64 -1
  322. package/templates/database/auth/schema.prisma +4 -0
  323. package/templates/database/config/schema.prisma +91 -0
  324. package/templates/database/inbox/schema.prisma +17 -2
  325. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  326. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  327. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  328. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  329. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  330. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  331. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  332. package/templates/database/package.json +1 -1
  333. package/templates/database/seed.ts +62 -3
  334. package/versions.json +35 -35
  335. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  336. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
  337. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  338. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  339. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  340. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  341. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  342. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -0,0 +1,200 @@
1
+ // oss: false
2
+ /**
3
+ * #8848 D8, AC-5 — the ending delivers the deliverable by kind. FlowStepper
4
+ * is the ending suite's stub: it submits the configured steps through the
5
+ * real `onSubmitStep` and renders the `completedContent` it is given.
6
+ */
7
+ import { render, screen } from "@testing-library/react";
8
+ import userEvent from "@testing-library/user-event";
9
+ import type { FlowConfig } from "@working-theory/validation";
10
+ import type { ReactElement, ReactNode } from "react";
11
+
12
+ const mockPush = jest.fn();
13
+ jest.mock("next/navigation", () => ({
14
+ useRouter: () => ({ push: mockPush }),
15
+ }));
16
+
17
+ jest.mock("next-intl", () => ({
18
+ useTranslations: (namespace?: string) => (key: string) => (namespace ? `${namespace}.${key}` : key),
19
+ useMessages: () => ({}),
20
+ }));
21
+
22
+ // eslint-disable-next-line no-restricted-syntax -- the boundary under test is the client wiring, not the actions
23
+ jest.mock("../flow.actions", () => ({ submitFlowStepAction: jest.fn() }));
24
+ // eslint-disable-next-line no-restricted-syntax -- see above
25
+ jest.mock("../account-invite.actions", () => ({ accountInviteLinkAction: jest.fn() }));
26
+
27
+ interface StubProps {
28
+ onSubmitStep: (payload: { stepKey: string; values: Record<string, unknown> }) => Promise<unknown>;
29
+ completedContent?: ReactNode;
30
+ }
31
+
32
+ jest.mock("@working-theory/ui-signal-collectors", () => ({
33
+ FlowStepper: (props: StubProps) => {
34
+ const react = require("react");
35
+ return react.createElement(
36
+ "div",
37
+ { "data-testid": "mock-card" },
38
+ react.createElement(
39
+ "button",
40
+ {
41
+ "data-testid": "mock-submit",
42
+ onClick: () => props.onSubmitStep({ stepKey: "contact", values: { email: "ana@example.com" } }),
43
+ },
44
+ "Submit",
45
+ ),
46
+ props.completedContent,
47
+ ) as ReactElement;
48
+ },
49
+ }));
50
+
51
+ import { submitFlowStepAction } from "../flow.actions";
52
+ import { FlowStepperClient } from "../FlowStepperClient";
53
+
54
+ const mockSubmit = submitFlowStepAction as jest.MockedFunction<typeof submitFlowStepAction>;
55
+ const en = (s: string) => ({ en: s });
56
+
57
+ /**
58
+ * A served config (#8848 D2): a gated block's target is projected out, which
59
+ * `FlowConfig` (the type `flows.get` answers with) cannot express — the one
60
+ * cast lives here.
61
+ */
62
+ function config(extra: Record<string, unknown> = {}): FlowConfig {
63
+ const served = {
64
+ slug: "q3",
65
+ defaultLocale: "en",
66
+ steps: [
67
+ {
68
+ key: "contact",
69
+ type: "field",
70
+ copy: { title: en("Get the report") },
71
+ fields: [{ name: "email", inputType: "email", required: true, mapsTo: "email", copy: { label: en("Email") } }],
72
+ },
73
+ ],
74
+ deliverable: {
75
+ kind: "file",
76
+ gated: true,
77
+ title: en("Q3 report"),
78
+ cta: en("Read the report"),
79
+ geometry: "letter",
80
+ pages: 3,
81
+ },
82
+ deliverables: { assets: [{ assetKey: "q3.pdf" }], copy: { subject: en("s"), body: en("b") } },
83
+ ...extra,
84
+ };
85
+ // eslint-disable-next-line no-restricted-syntax -- a served gated block omits its target, which FlowConfig cannot express (see above)
86
+ return served as unknown as FlowConfig;
87
+ }
88
+
89
+ function respondWith(deliverable?: unknown): void {
90
+ mockSubmit.mockResolvedValue({
91
+ ok: true,
92
+ response: {
93
+ sessionId: "s1",
94
+ landed: "contact",
95
+ nextStepKey: null,
96
+ completed: true,
97
+ ...(deliverable !== undefined ? { deliverable } : {}),
98
+ },
99
+ } as Awaited<ReturnType<typeof submitFlowStepAction>>);
100
+ }
101
+
102
+ async function walk(cfg: FlowConfig, locale = "en"): Promise<void> {
103
+ render(<FlowStepperClient config={cfg} locale={locale} slug="q3" />);
104
+ await userEvent.setup().click(screen.getByTestId("mock-submit"));
105
+ }
106
+
107
+ beforeEach(() => {
108
+ jest.clearAllMocks();
109
+ });
110
+
111
+ describe("the ending delivers the deliverable (#8848 D8, AC-5)", () => {
112
+ it("a file response links the reader with its grant, labelled the block's cta", async () => {
113
+ respondWith({ kind: "file", grantId: "dgrant_x" });
114
+ await walk(config());
115
+
116
+ const action = await screen.findByTestId("flow-deliverable-action");
117
+ // `localeHref`: the default locale is unprefixed (`localePrefix: "as-needed"`).
118
+ expect(action.getAttribute("href")).toBe("/reports/q3?g=dgrant_x");
119
+ expect(action.textContent).toBe("Read the report");
120
+ expect(action.closest("[data-sentry-block]")).not.toBeNull();
121
+ });
122
+
123
+ it("prefixes another locale's reader link", async () => {
124
+ respondWith({ kind: "file", grantId: "dgrant_x" });
125
+ await walk(config(), "fr");
126
+
127
+ expect((await screen.findByTestId("flow-deliverable-action")).getAttribute("href")).toBe(
128
+ "/fr/reports/q3?g=dgrant_x",
129
+ );
130
+ });
131
+
132
+ it("a redirect ending never navigates away from the deliverable", async () => {
133
+ respondWith({ kind: "file", grantId: "dgrant_x" });
134
+ await walk(config({ ending: { default: { kind: "redirect", href: "/pricing" } } }));
135
+
136
+ expect(await screen.findByTestId("flow-deliverable-action")).toBeTruthy();
137
+ expect(mockPush).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it("a screen ending renders first, then the action", async () => {
141
+ respondWith({ kind: "file", grantId: "dgrant_x" });
142
+ await walk(config({ ending: { default: { kind: "screen", headline: en("Done") } } }));
143
+
144
+ const ending = await screen.findByTestId("flow-ending");
145
+ const action = screen.getByTestId("flow-deliverable-action");
146
+ expect(ending.compareDocumentPosition(action) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
147
+ });
148
+
149
+ it("a file walk with no deliverable in the response shows the by-email line and no link", async () => {
150
+ respondWith();
151
+ await walk(config());
152
+
153
+ expect(await screen.findByText("reports.fileByEmail")).toBeTruthy();
154
+ expect(screen.queryByTestId("flow-deliverable-action")).toBeNull();
155
+ });
156
+
157
+ it("a video response embeds the player", async () => {
158
+ respondWith({ kind: "video", src: "https://cdn.example/v.mp4" });
159
+ await walk(config({ deliverable: { kind: "video", gated: true, title: en("Talk") }, deliverables: undefined }));
160
+
161
+ const box = await screen.findByTestId("flow-deliverable-video");
162
+ await userEvent.setup().click(screen.getByTestId("video-embed-play-button"));
163
+ expect(box.querySelector("video source")?.getAttribute("src")).toBe("https://cdn.example/v.mp4");
164
+ });
165
+
166
+ it("a page response links the site page", async () => {
167
+ respondWith({ kind: "page", href: "/calc" });
168
+ await walk(config({ deliverable: { kind: "page", gated: true, title: en("Calc") }, deliverables: undefined }));
169
+
170
+ expect((await screen.findByTestId("flow-deliverable-action")).getAttribute("href")).toBe("/calc");
171
+ });
172
+
173
+ it.each([
174
+ { kind: "page", href: "//evil.example" },
175
+ { kind: "external", href: "javascript:alert(1)" },
176
+ { kind: "video", src: "http://cdn.example/v.mp4" },
177
+ ])("renders no link and no video for an unsafe target: %j", async (deliverable) => {
178
+ respondWith(deliverable);
179
+ await walk(
180
+ config({
181
+ deliverable: { kind: deliverable.kind, gated: true, title: en("X") },
182
+ deliverables: undefined,
183
+ }),
184
+ );
185
+
186
+ await screen.findByTestId("mock-card");
187
+ expect(screen.queryByTestId("flow-deliverable-action")).toBeNull();
188
+ expect(screen.queryByTestId("flow-deliverable-video")).toBeNull();
189
+ expect(document.querySelector("video")).toBeNull();
190
+ });
191
+
192
+ it("a config with no deliverable and no response key renders today's card", async () => {
193
+ respondWith();
194
+ await walk(config({ deliverable: undefined, deliverables: undefined }));
195
+
196
+ await screen.findByTestId("mock-card");
197
+ expect(screen.queryByTestId("flow-deliverable-action")).toBeNull();
198
+ expect(screen.queryByText("reports.fileByEmail")).toBeNull();
199
+ });
200
+ });
@@ -0,0 +1,197 @@
1
+ // oss: false
2
+ /**
3
+ * #8840 D9, AC-3 — FlowStepperClient's standard-magnet wiring: the locale's
4
+ * policy links, the input-step count, and the flow's own ending (a screen in
5
+ * the finished card or a redirect to a site path, never `thankYou` once an
6
+ * ending exists). FlowStepper is a stub that records its props, submits the
7
+ * configured steps through the real `onSubmitStep`, and renders the
8
+ * `completedContent` it is given — as the invite test's stub does.
9
+ */
10
+ import { render, screen, waitFor } from "@testing-library/react";
11
+ import userEvent from "@testing-library/user-event";
12
+ import type { FlowConfig } from "@working-theory/validation";
13
+ import type { ReactElement, ReactNode } from "react";
14
+
15
+ const mockPush = jest.fn();
16
+ jest.mock("next/navigation", () => ({
17
+ useRouter: () => ({ push: mockPush }),
18
+ }));
19
+
20
+ jest.mock("next-intl", () => ({
21
+ useTranslations: () => (key: string) => key,
22
+ useMessages: () => ({}),
23
+ }));
24
+
25
+ // eslint-disable-next-line no-restricted-syntax -- the boundary under test is the client wiring, not the actions
26
+ jest.mock("../flow.actions", () => ({ submitFlowStepAction: jest.fn() }));
27
+ // eslint-disable-next-line no-restricted-syntax -- see above
28
+ jest.mock("../account-invite.actions", () => ({ accountInviteLinkAction: jest.fn() }));
29
+
30
+ type Step = { stepKey: string; values: Record<string, unknown> };
31
+ let steps: Step[] = [];
32
+
33
+ interface StubProps {
34
+ onSubmitStep: (payload: Step) => Promise<{ ok: boolean; response?: { completed: boolean } }>;
35
+ onCompleted?: unknown;
36
+ uncountedStepKeys?: readonly string[];
37
+ consentConfig?: { policies: Record<string, string> };
38
+ completedContent?: ReactNode;
39
+ hiddenValues?: Record<string, string>;
40
+ header?: ReactNode;
41
+ }
42
+ let lastProps: StubProps | undefined;
43
+
44
+ jest.mock("@working-theory/ui-signal-collectors", () => ({
45
+ FlowStepper: (props: StubProps) => {
46
+ lastProps = props;
47
+ async function handleSubmit(): Promise<void> {
48
+ for (const step of steps) await props.onSubmitStep(step);
49
+ }
50
+ const react = require("react");
51
+ return react.createElement(
52
+ "div",
53
+ { "data-testid": "mock-card" },
54
+ react.createElement("button", { "data-testid": "mock-submit", onClick: handleSubmit }, "Submit"),
55
+ props.completedContent,
56
+ ) as ReactElement;
57
+ },
58
+ }));
59
+
60
+ import { submitFlowStepAction } from "../flow.actions";
61
+ import { FlowStepperClient } from "../FlowStepperClient";
62
+
63
+ const mockSubmit = submitFlowStepAction as jest.MockedFunction<typeof submitFlowStepAction>;
64
+ const en = (s: string) => ({ en: s });
65
+
66
+ function baseConfig(extra: Partial<FlowConfig> = {}): FlowConfig {
67
+ return {
68
+ slug: "growth",
69
+ defaultLocale: "en",
70
+ steps: [
71
+ {
72
+ key: "about",
73
+ type: "field",
74
+ copy: { title: en("About you") },
75
+ fields: [
76
+ { name: "first_name", inputType: "text", mapsTo: "givenName", copy: { label: en("First name") } },
77
+ { name: "email", inputType: "email", required: true, mapsTo: "email", copy: { label: en("Email") } },
78
+ ],
79
+ },
80
+ {
81
+ key: "pace",
82
+ type: "segmentation",
83
+ copy: { title: en("Pace?") },
84
+ questions: [{ name: "pace", options: [{ value: "fast", label: en("Fast") }, { value: "slow", label: en("Slow") }] }],
85
+ },
86
+ { key: "thanks", type: "message", copy: { title: en("Thanks") } },
87
+ ],
88
+ ...extra,
89
+ } as FlowConfig;
90
+ }
91
+
92
+ function completeOn(lastKey: string): void {
93
+ mockSubmit.mockImplementation(async (_slug, payload) => ({
94
+ ok: true,
95
+ response: {
96
+ sessionId: "s1",
97
+ landed: payload.stepKey,
98
+ nextStepKey: null,
99
+ completed: payload.stepKey === lastKey,
100
+ },
101
+ }));
102
+ }
103
+
104
+ beforeEach(() => {
105
+ jest.clearAllMocks();
106
+ lastProps = undefined;
107
+ steps = [
108
+ { stepKey: "about", values: { first_name: "Ana", email: "ana@example.com" } },
109
+ { stepKey: "pace", values: { pace: "fast" } },
110
+ ];
111
+ completeOn("pace");
112
+ });
113
+
114
+ async function walk(config: FlowConfig, locale = "en"): Promise<void> {
115
+ render(<FlowStepperClient config={config} locale={locale} slug={config.slug} />);
116
+ await userEvent.setup().click(screen.getByTestId("mock-submit"));
117
+ }
118
+
119
+ describe("the count and the registry (#8840 D9)", () => {
120
+ it("passes every message step's key as uncounted", () => {
121
+ render(<FlowStepperClient config={baseConfig()} locale="en" slug="growth" />);
122
+ expect(lastProps?.uncountedStepKeys).toEqual(["thanks"]);
123
+ });
124
+
125
+ it("localizes the policy links", () => {
126
+ render(<FlowStepperClient config={baseConfig()} locale="fr" slug="growth" />);
127
+ expect(lastProps?.consentConfig?.policies["terms"]).toBe("/fr/terms");
128
+ expect(lastProps?.consentConfig?.policies["privacy"]).toBe("/fr/privacy");
129
+ });
130
+
131
+ it("passes hiddenValues and header through", () => {
132
+ render(
133
+ <FlowStepperClient
134
+ config={baseConfig()}
135
+ locale="en"
136
+ slug="growth"
137
+ hiddenValues={{ job: "writer" }}
138
+ header={<span>Back</span>}
139
+ />,
140
+ );
141
+ expect(lastProps?.hiddenValues).toEqual({ job: "writer" });
142
+ expect(lastProps?.header).toBeDefined();
143
+ });
144
+ });
145
+
146
+ describe("the ending wins (#8840 D9)", () => {
147
+ const screenEnding = {
148
+ default: {
149
+ kind: "screen" as const,
150
+ headline: en("Thanks, {name}."),
151
+ body: en("We'll be in touch."),
152
+ ctas: [
153
+ { label: en("Book a call"), href: "/meet/intro" },
154
+ // Injected past the schema: the client re-checks every destination.
155
+ { label: en("Elsewhere"), href: "//evil.example" },
156
+ ],
157
+ },
158
+ };
159
+
160
+ it("with an ending and a thankYou, passes no onCompleted", () => {
161
+ render(
162
+ <FlowStepperClient
163
+ config={baseConfig({ ending: screenEnding, thankYou: { default: "thanks" } })}
164
+ locale="en"
165
+ slug="growth"
166
+ />,
167
+ );
168
+ expect(lastProps?.onCompleted).toBeUndefined();
169
+ });
170
+
171
+ it("a completing response renders the filled headline and the localized on-site CTA only", async () => {
172
+ await walk(baseConfig({ ending: screenEnding, thankYou: { default: "thanks" } }), "fr");
173
+ await screen.findByTestId("flow-ending");
174
+ expect(screen.getByRole("heading", { level: 2 }).textContent).toBe("Thanks, Ana.");
175
+ const links = screen.getAllByTestId("flow-ending-cta");
176
+ expect(links.map((a) => a.getAttribute("href"))).toEqual(["/fr/meet/intro"]);
177
+ expect(mockPush).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it("a redirect ending pushes the locale-prefixed site path", async () => {
181
+ await walk(baseConfig({ ending: { default: { kind: "redirect", href: "/pricing?plan=pro" } } }));
182
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/en/pricing?plan=pro"));
183
+ });
184
+
185
+ it("a redirect href injected past the schema is never pushed", async () => {
186
+ await walk(baseConfig({ ending: { default: { kind: "redirect", href: "//evil.example" } } }));
187
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(2));
188
+ expect(mockPush).not.toHaveBeenCalled();
189
+ expect(screen.queryByTestId("flow-ending")).toBeNull();
190
+ });
191
+
192
+ it("with no ending, nothing new renders and thankYou still navigates", async () => {
193
+ await walk(baseConfig({ thankYou: { default: "thanks" } }));
194
+ expect(lastProps?.completedContent).toBeUndefined();
195
+ expect(typeof lastProps?.onCompleted).toBe("function");
196
+ });
197
+ });
@@ -0,0 +1,257 @@
1
+ // oss: false
2
+ /**
3
+ * #8771 AC-4 — the walk shows "Track this in your account" with the email the
4
+ * visitor gave already filled in, and keeps that email out of every URL it
5
+ * navigates to. FlowStepper is a stub that submits a configurable step and
6
+ * calls the real `onCompleted` it is given; the Server Actions are doubles.
7
+ */
8
+ import { render, screen, waitFor } from "@testing-library/react";
9
+ import userEvent from "@testing-library/user-event";
10
+ import type { FlowConfig } from "@working-theory/validation";
11
+ import type { ReactElement } from "react";
12
+
13
+ const mockPush = jest.fn();
14
+ jest.mock("next/navigation", () => ({
15
+ useRouter: () => ({ push: mockPush }),
16
+ }));
17
+
18
+ jest.mock("next-intl", () => ({
19
+ useTranslations: () => (key: string) => key,
20
+ useMessages: () => ({}),
21
+ }));
22
+
23
+ // eslint-disable-next-line no-restricted-syntax -- the boundary under test is the client wiring, not the actions
24
+ jest.mock("../flow.actions", () => ({ submitFlowStepAction: jest.fn() }));
25
+ // eslint-disable-next-line no-restricted-syntax -- see above
26
+ jest.mock("../account-invite.actions", () => ({ accountInviteLinkAction: jest.fn() }));
27
+
28
+ type Step = { stepKey: string; values: Record<string, unknown> };
29
+ let steps: Step[] = [];
30
+
31
+ interface StubProps {
32
+ onSubmitStep: (payload: Step & { locale?: string }) => Promise<{
33
+ ok: boolean;
34
+ response?: { completed: boolean; segment?: string };
35
+ }>;
36
+ onCompleted?: (result: { segment?: string }) => void;
37
+ completedAction?: ReactElement;
38
+ }
39
+
40
+ jest.mock("@working-theory/ui-signal-collectors", () => ({
41
+ FlowStepper: (props: StubProps) => {
42
+ async function handleSubmit(): Promise<void> {
43
+ for (const step of steps) {
44
+ const result = await props.onSubmitStep({ ...step, locale: "en" });
45
+ if (result.ok && result.response?.completed) {
46
+ props.onCompleted?.(
47
+ result.response.segment !== undefined ? { segment: result.response.segment } : {},
48
+ );
49
+ }
50
+ }
51
+ }
52
+ // The real FlowStepper renders `completedAction` inside its finished card.
53
+ return require("react").createElement(
54
+ "div",
55
+ { "data-testid": "mock-completed-card" },
56
+ require("react").createElement(
57
+ "button",
58
+ { "data-testid": "mock-submit", onClick: handleSubmit },
59
+ "Submit",
60
+ ),
61
+ props.completedAction,
62
+ ) as ReactElement;
63
+ },
64
+ }));
65
+
66
+ import { accountInviteLinkAction } from "../account-invite.actions";
67
+ import { submitFlowStepAction } from "../flow.actions";
68
+ import { FlowStepperClient } from "../FlowStepperClient";
69
+
70
+ const mockSubmit = submitFlowStepAction as jest.MockedFunction<typeof submitFlowStepAction>;
71
+ const mockLink = accountInviteLinkAction as jest.MockedFunction<typeof accountInviteLinkAction>;
72
+
73
+ const HREF = "/sign-in?next=%2Faccount&email=jane%40example.com";
74
+ const EMAIL = "jane@example.com";
75
+
76
+ function config(opts: { tagged: boolean; thankYou: boolean; secondEmail?: boolean }): FlowConfig {
77
+ return {
78
+ slug: "get-a-quote",
79
+ defaultLocale: "en",
80
+ steps: [
81
+ {
82
+ key: "contact",
83
+ type: "field",
84
+ copy: { title: { en: "Let's get started" } },
85
+ fields: [
86
+ {
87
+ name: "email",
88
+ inputType: "email",
89
+ required: true,
90
+ ...(opts.tagged ? { mapsTo: "email" } : {}),
91
+ copy: { label: { en: "Email" } },
92
+ },
93
+ ...(opts.secondEmail
94
+ ? [
95
+ {
96
+ name: "backup",
97
+ inputType: "email",
98
+ ...(opts.tagged ? { mapsTo: "email" } : {}),
99
+ copy: { label: { en: "Backup" } },
100
+ },
101
+ ]
102
+ : []),
103
+ ],
104
+ },
105
+ ],
106
+ ...(opts.thankYou
107
+ ? { thankYou: { default: "flow-thanks", bySegment: { qualified: "flow-thanks-qualified" } } }
108
+ : {}),
109
+ } as FlowConfig;
110
+ }
111
+
112
+ function ok(response: Record<string, unknown>) {
113
+ return {
114
+ ok: true as const,
115
+ response: {
116
+ sessionId: "s",
117
+ landed: "contact",
118
+ nextStepKey: null,
119
+ completed: true,
120
+ ...response,
121
+ },
122
+ } as never;
123
+ }
124
+
125
+ async function walk(cfg: FlowConfig): Promise<void> {
126
+ render(<FlowStepperClient config={cfg} locale="en" slug="get-a-quote" />);
127
+ await userEvent.click(screen.getByTestId("mock-submit"));
128
+ }
129
+
130
+ beforeEach(() => {
131
+ mockPush.mockClear();
132
+ mockSubmit.mockReset();
133
+ mockLink.mockReset();
134
+ mockLink.mockResolvedValue(HREF);
135
+ steps = [{ stepKey: "contact", values: { email: EMAIL } }];
136
+ window.sessionStorage.clear();
137
+ });
138
+
139
+ describe("the finished panel (no thankYou block)", () => {
140
+ it("shows the prefilled link inside a Replay-blocked element", async () => {
141
+ mockSubmit.mockResolvedValue(ok({ next: { completion: "invite" } }));
142
+
143
+ await walk(config({ tagged: true, thankYou: false }));
144
+
145
+ const link = await screen.findByTestId("flow-account-invite");
146
+ expect(link).toHaveAttribute("href", HREF);
147
+ expect(link).toHaveTextContent("trackInAccount");
148
+ expect(link.closest("[data-sentry-block]")).not.toBeNull();
149
+ // Inside the finished card, not a sibling after the frame (#8771 design review).
150
+ expect(link.closest('[data-testid="mock-completed-card"]')).not.toBeNull();
151
+ expect(mockLink).toHaveBeenCalledTimes(1);
152
+ expect(mockLink).toHaveBeenCalledWith(EMAIL);
153
+ });
154
+
155
+ it("only an email-tagged field counts", async () => {
156
+ mockSubmit.mockResolvedValue(ok({ next: { completion: "invite" } }));
157
+
158
+ await walk(config({ tagged: false, thankYou: false }));
159
+
160
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalled());
161
+ expect(mockLink).not.toHaveBeenCalled();
162
+ expect(screen.queryByTestId("flow-account-invite")).toBeNull();
163
+ });
164
+
165
+ it("uses the step's first tagged email field", async () => {
166
+ steps = [{ stepKey: "contact", values: { email: "first@example.com", backup: "second@example.com" } }];
167
+ mockSubmit.mockResolvedValue(ok({ next: { completion: "invite" } }));
168
+
169
+ await walk(config({ tagged: true, thankYou: false, secondEmail: true }));
170
+
171
+ await screen.findByTestId("flow-account-invite");
172
+ expect(mockLink).toHaveBeenCalledWith("first@example.com");
173
+ });
174
+
175
+ it.each([
176
+ ["no next", { }],
177
+ ["a step that does not complete", { completed: false, next: { completion: "invite" } }],
178
+ ])("%s: no action call and no link", async (_name, response) => {
179
+ mockSubmit.mockResolvedValue(ok(response));
180
+
181
+ await walk(config({ tagged: true, thankYou: false }));
182
+
183
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalled());
184
+ expect(mockLink).not.toHaveBeenCalled();
185
+ expect(screen.queryByTestId("flow-account-invite")).toBeNull();
186
+ });
187
+
188
+ it("an action answer of null shows no link", async () => {
189
+ mockSubmit.mockResolvedValue(ok({ next: { completion: "invite" } }));
190
+ mockLink.mockResolvedValue(null);
191
+
192
+ await walk(config({ tagged: true, thankYou: false }));
193
+
194
+ await waitFor(() => expect(mockLink).toHaveBeenCalled());
195
+ expect(screen.queryByTestId("flow-account-invite")).toBeNull();
196
+ });
197
+
198
+ it("a conflict forgets the email: a later completing step with no email field asks for no link", async () => {
199
+ mockSubmit
200
+ .mockResolvedValueOnce(ok({ completed: false }))
201
+ .mockResolvedValueOnce({ ok: false, kind: "step_conflict", message: "x" } as never)
202
+ .mockResolvedValueOnce(ok({ next: { completion: "invite" } }));
203
+ steps = [
204
+ { stepKey: "contact", values: { email: EMAIL } },
205
+ { stepKey: "contact", values: { email: EMAIL } },
206
+ { stepKey: "other", values: { note: "hi" } },
207
+ ];
208
+
209
+ await walk(config({ tagged: true, thankYou: false }));
210
+
211
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(3));
212
+ expect(mockLink).not.toHaveBeenCalled();
213
+ });
214
+ });
215
+
216
+ describe("the /thanks hand-off (a thankYou block)", () => {
217
+ it("writes one sessionStorage item and pushes a URL with no email in it", async () => {
218
+ mockSubmit.mockResolvedValue(ok({ segment: "qualified", next: { completion: "invite" } }));
219
+
220
+ await walk(config({ tagged: true, thankYou: true }));
221
+
222
+ await waitFor(() => expect(mockPush).toHaveBeenCalled());
223
+ expect(JSON.parse(window.sessionStorage.getItem("ailk:account-invite") ?? "null")).toEqual({
224
+ thanks: "flow-thanks-qualified",
225
+ href: HREF,
226
+ });
227
+ const url = mockPush.mock.calls[0]?.[0] as string;
228
+ expect(url).toBe("/en/thanks/flow-thanks-qualified");
229
+ expect(url).not.toContain("@");
230
+ expect(screen.queryByTestId("flow-account-invite")).toBeNull();
231
+ });
232
+
233
+ it("clears an earlier walk's hand-off when this completion has no link", async () => {
234
+ window.sessionStorage.setItem(
235
+ "ailk:account-invite",
236
+ JSON.stringify({ thanks: "flow-thanks", href: HREF }),
237
+ );
238
+ mockSubmit.mockResolvedValue(ok({}));
239
+
240
+ await walk(config({ tagged: true, thankYou: true }));
241
+
242
+ await waitFor(() => expect(mockPush).toHaveBeenCalled());
243
+ expect(window.sessionStorage.getItem("ailk:account-invite")).toBeNull();
244
+ });
245
+
246
+ it("a sessionStorage that throws still navigates", async () => {
247
+ mockSubmit.mockResolvedValue(ok({ next: { completion: "invite" } }));
248
+ const spy = jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
249
+ throw new Error("denied");
250
+ });
251
+
252
+ await walk(config({ tagged: true, thankYou: true }));
253
+
254
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/en/thanks/flow-thanks"));
255
+ spy.mockRestore();
256
+ });
257
+ });