create-ailk 0.22.0 → 0.24.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 (287) hide show
  1. package/README.md +27 -14
  2. package/component-catalog.json +149 -5
  3. package/component-catalog.md +7 -6
  4. package/dist/add-module.js +17 -10
  5. package/dist/add-route.d.ts +55 -1
  6. package/dist/add-route.js +301 -13
  7. package/dist/cli.js +27 -10
  8. package/dist/derive-architecture.d.ts +7 -5
  9. package/dist/derive-architecture.js +26 -32
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +0 -4
  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 +36 -2
  15. package/dist/lib/scaffold-render-sweep.js +50 -8
  16. package/dist/module-architecture.js +115 -10
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/seed-flows.d.ts +4 -1
  20. package/dist/seed-flows.js +50 -4
  21. package/dist/spoke-checkout.d.ts +38 -35
  22. package/dist/spoke-checkout.js +66 -229
  23. package/dist/starter-grid.d.ts +0 -3
  24. package/dist/starter-grid.js +24 -14
  25. package/dist/surfaces.js +33 -12
  26. package/dist/sync-routes.d.ts +68 -5
  27. package/dist/sync-routes.js +455 -29
  28. package/dist/template-record.d.ts +1 -1
  29. package/dist/template-record.js +3 -2
  30. package/dist/update.js +10 -0
  31. package/package.json +3 -3
  32. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  33. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  35. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  38. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  40. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  41. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  43. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  44. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  46. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  47. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  48. package/templates/.env.example +13 -7
  49. package/templates/.github/workflows/ci.yml +13 -0
  50. package/templates/CLAUDE.md +4 -2
  51. package/templates/README.md +1 -0
  52. package/templates/_gitignore +4 -0
  53. package/templates/apps/api/.env.example +34 -0
  54. package/templates/apps/api/CLAUDE.md +94 -19
  55. package/templates/apps/api/package.json +1 -0
  56. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  57. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  58. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  59. package/templates/apps/api/src/config/index.ts +109 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
  61. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  62. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  63. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  64. package/templates/apps/api/src/lib/site.ts +4 -1
  65. package/templates/apps/api/src/lib/stripe.ts +77 -4
  66. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  67. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  68. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  69. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  70. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  71. package/templates/apps/api/src/openapi/spec.ts +343 -7
  72. package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
  73. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  74. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
  75. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  76. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  77. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  78. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  79. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  80. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  81. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  82. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  83. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  84. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  85. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  87. package/templates/apps/api/src/routes/content/index.ts +16 -2
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  89. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  90. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  91. package/templates/apps/api/src/routes/content/search.ts +16 -45
  92. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  93. package/templates/apps/api/src/routes/leads/index.ts +377 -308
  94. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  95. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  96. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  97. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  98. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  99. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  100. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  101. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
  102. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  103. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  104. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  105. package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
  106. package/templates/apps/api/src/server.ts +165 -51
  107. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  108. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  109. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
  110. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  111. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  112. package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
  113. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
  114. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  115. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  116. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  117. package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
  118. package/templates/apps/api/src/services/entitlements.ts +157 -1
  119. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  120. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
  121. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  122. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
  123. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  124. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
  125. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
  126. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  127. package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
  128. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
  129. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  130. package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
  131. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  132. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  133. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  134. package/templates/apps/api/src/services/person.ts +147 -9
  135. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  136. package/templates/apps/api/src/services/project-listings.ts +43 -31
  137. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  138. package/templates/apps/mcp/CLAUDE.md +32 -18
  139. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  140. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  141. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  142. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  143. package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
  144. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  145. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  146. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  147. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  148. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  149. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  150. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  151. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  152. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  153. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  154. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  155. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  156. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  157. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  158. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  159. package/templates/apps/mcp/package.json +1 -0
  160. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  161. package/templates/apps/mcp/src/http-transport.ts +47 -9
  162. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  163. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  164. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  165. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  166. package/templates/apps/mcp/src/server.ts +5 -0
  167. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  168. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  169. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  170. package/templates/apps/mcp/src/tools/index.ts +22 -21
  171. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  172. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  173. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  174. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  175. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  176. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  177. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  178. package/templates/apps/web/.env.example +10 -31
  179. package/templates/apps/web/CLAUDE.md +324 -42
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  183. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  184. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  185. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  186. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  187. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  188. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  189. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  190. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  191. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  192. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  193. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  194. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  195. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  196. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  197. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
  198. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  199. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  200. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  201. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  202. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  203. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  204. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  205. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  206. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
  207. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  208. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  209. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  210. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  211. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  212. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  213. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  214. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  215. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  216. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  217. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  218. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  219. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  220. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  221. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  222. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  223. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  224. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  225. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  226. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  227. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
  228. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  229. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  230. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  231. package/templates/apps/web/lib/auth-routes.ts +36 -2
  232. package/templates/apps/web/lib/checkout-config.ts +9 -20
  233. package/templates/apps/web/lib/deck-page.ts +1 -0
  234. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  235. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  236. package/templates/apps/web/lib/magnet-routes.ts +44 -0
  237. package/templates/apps/web/lib/modules.generated.ts +9 -0
  238. package/templates/apps/web/lib/newsletter.actions.ts +205 -0
  239. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  240. package/templates/apps/web/lib/posting-title.ts +17 -0
  241. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  242. package/templates/apps/web/messages/en.json +49 -15
  243. package/templates/apps/web/middleware.ts +12 -2
  244. package/templates/apps/web/security-headers.config.ts +17 -0
  245. package/templates/content/en/BlogList/blog.mdx +4 -71
  246. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  247. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  248. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  249. package/templates/content/en/BookCall/start-project.mdx +1 -17
  250. package/templates/content/en/ContactPage/contact.mdx +6 -63
  251. package/templates/content/en/HomePage/home.mdx +3 -21
  252. package/templates/database/CHANGELOG.md +51 -0
  253. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  254. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  255. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  256. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  257. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  258. package/templates/database/__tests__/seed.test.ts +63 -1
  259. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  260. package/templates/database/auth/schema.prisma +4 -0
  261. package/templates/database/config/schema.prisma +91 -0
  262. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  263. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  264. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  265. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  266. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  267. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  268. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  269. package/templates/database/package.json +1 -1
  270. package/templates/database/seed.ts +57 -2
  271. package/templates/tools/lint-architecture.ts +205 -1
  272. package/versions.json +35 -35
  273. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  274. package/templates/apps/api/src/routes/content/slug.ts +0 -9
  275. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  276. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  277. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  278. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  279. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  280. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  281. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  282. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  283. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  284. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  285. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  286. package/templates/apps/web/lib/checkout.ts +0 -93
  287. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -44,6 +44,26 @@
44
44
  * URL the walk navigates to (NFR1), and each link sits in a Session-Replay
45
45
  * `data-sentry-block` element, since the scrub cannot reach a DOM `href`.
46
46
  *
47
+ * The standard magnet's parts (#8840 D9): the consent registry is the
48
+ * platform default with the locale's `/terms` and `/privacy` links
49
+ * (`consentPoliciesFor`, the map every agreement surface uses); a `message`
50
+ * step is shown but not counted in "Step N of M"; `hiddenValues` and `header`
51
+ * pass through from the page. When the config carries an `ending`, it is the
52
+ * one ending: `onCompleted` is not passed (so neither `thankYou` nor a
53
+ * recommending flow's permalink runs), and on the completing response the
54
+ * client picks the outcome from the answers it accumulated
55
+ * (`selectFlowEndingOutcome`) — a screen in the stepper's finished card,
56
+ * `{name}` filled from the walk, or a redirect to a site path. Every
57
+ * destination is re-checked against `flowSitePathSchema` before it renders or
58
+ * navigates (the served config is not re-parsed, SEC-8840-H).
59
+ *
60
+ * The deliverable (#8848 D8): a completing response's `deliverable` is drawn
61
+ * after the ending screen — the reader link for a file (its grant in `?g=`,
62
+ * inside a `data-sentry-block` element), an inline player for a video, a link
63
+ * for a page or an outside address — each target re-checked before it
64
+ * renders. A redirect ending never runs when the response carries one, and a
65
+ * gated file walk whose response has none says the link is in the email.
66
+ *
47
67
  * Locale validation before interpolation (AILK #4707, security review on
48
68
  * samhenry PR #163): the `[locale]` route segment is a Next.js dynamic
49
69
  * segment — the framework matches ANY string there, and next-intl's
@@ -58,29 +78,46 @@
58
78
  * bundle, the same reasoning `middleware.ts` already documents for the
59
79
  * Edge runtime) before it ever reaches the template literal.
60
80
  */
61
- import { Button, Link } from "@working-theory/ui";
81
+ import { Button, Heading, Link, localeHref, Text } from "@working-theory/ui";
82
+ import { VideoEmbed } from "@working-theory/ui/blocks";
62
83
  import {
63
84
  FlowStepper,
64
85
  type FlowStepperMessages,
65
86
  type FlowStepSubmitPayload,
66
87
  type FlowStepSubmitResult,
67
88
  } from "@working-theory/ui-signal-collectors";
68
- import type { AeoScoreResult, FlowConfig, FlowState } from "@working-theory/validation";
89
+ import type {
90
+ AeoScoreResult,
91
+ FlowConfig,
92
+ FlowEndingOutcome,
93
+ FlowState,
94
+ FlowStepDeliverable,
95
+ } from "@working-theory/validation";
69
96
  import {
70
97
  deriveFlags,
71
98
  encodePermalink,
99
+ fillEndingName,
72
100
  flattenState,
101
+ flowHttpsUrlSchema,
102
+ flowSitePathSchema,
103
+ flowStepDeliverableSchema,
73
104
  flowStepNextSchema,
74
105
  hiddenAnswers,
75
106
  PERMALINK_VERSION,
76
107
  pruneHiddenState,
108
+ resolveLocalizedString,
77
109
  resolveThankYouSlug,
110
+ selectFlowEndingOutcome,
111
+ SITE_CONSENT_CONFIG_DEFAULTS,
112
+ visitorNameOf,
78
113
  } from "@working-theory/validation";
79
114
  import { useRouter } from "next/navigation";
80
115
  import { useMessages, useTranslations } from "next-intl";
81
- import type { ReactElement } from "react";
116
+ import type { ReactElement, ReactNode } from "react";
82
117
  import { useRef, useState } from "react";
83
118
 
119
+ import { consentPoliciesFor } from "../../../../lib/consent-policies";
120
+
84
121
  import { accountInviteLinkAction } from "./account-invite.actions";
85
122
  import { submitFlowStepAction } from "./flow.actions";
86
123
 
@@ -88,6 +125,10 @@ interface Props {
88
125
  config: FlowConfig;
89
126
  locale: string;
90
127
  slug: string;
128
+ /** #8840 D9 — page-supplied `hidden` field values (a posting's slug). */
129
+ hiddenValues?: Readonly<Record<string, string>>;
130
+ /** #8840 D9 — the walk's back arrow, above the step. */
131
+ header?: ReactNode;
91
132
  }
92
133
 
93
134
  const DEFAULT_LOCALE = "en";
@@ -122,6 +163,24 @@ function mappedEmailOf(
122
163
  return undefined;
123
164
  }
124
165
 
166
+ /**
167
+ * #8848 D8 — the completing response's `deliverable`, each target re-checked:
168
+ * a video whose `src` is refused is nothing, a refused `poster` is dropped;
169
+ * every other kind must parse whole.
170
+ */
171
+ function safeDeliverable(raw: unknown): FlowStepDeliverable | undefined {
172
+ if (typeof raw !== "object" || raw === null) return undefined;
173
+ const video = raw as { kind?: unknown; src?: unknown; poster?: unknown };
174
+ if (video.kind === "video") {
175
+ const src = flowHttpsUrlSchema.safeParse(video.src);
176
+ if (!src.success) return undefined;
177
+ const poster = flowHttpsUrlSchema.safeParse(video.poster);
178
+ return { kind: "video", src: src.data, ...(poster.success ? { poster: poster.data } : {}) };
179
+ }
180
+ const parsed = flowStepDeliverableSchema.safeParse(raw);
181
+ return parsed.success ? parsed.data : undefined;
182
+ }
183
+
125
184
  /**
126
185
  * D7 (#4770) — every question `name` contributed by a `segmentation`-typed
127
186
  * step. This is the allowlist the encoded permalink is filtered to: a
@@ -140,10 +199,23 @@ function segmentationQuestionNames(config: FlowConfig): Set<string> {
140
199
  return names;
141
200
  }
142
201
 
202
+ /**
203
+ * #8840 D9 — the answers the ending is chosen from, assembled as the
204
+ * permalink path assembles them: the visible steps' answers flattened, then
205
+ * the hidden segmentation steps' `whenHidden` answers.
206
+ */
207
+ function walkAnswers(config: FlowConfig, state: FlowState): Record<string, unknown> {
208
+ const flags = deriveFlags(config, state);
209
+ const flat = flattenState(pruneHiddenState(config, state, flags));
210
+ return Object.assign(flat, hiddenAnswers(config, state, flags));
211
+ }
212
+
143
213
  export function FlowStepperClient({
144
214
  config,
145
215
  locale,
146
216
  slug,
217
+ hiddenValues,
218
+ header,
147
219
  }: Props): ReactElement {
148
220
  const t = useTranslations("flow");
149
221
  const router = useRouter();
@@ -171,6 +243,13 @@ export function FlowStepperClient({
171
243
  const inviteHrefRef = useRef<string | null>(null);
172
244
  const [inviteHref, setInviteHref] = useState<string | null>(null);
173
245
  const tContact = useTranslations("contact");
246
+ // #8840 D9 — a config with an `ending` ends on it, never on `thankYou` or
247
+ // the permalink; the chosen screen is drawn in the finished card.
248
+ const endingWins = config.ending !== undefined;
249
+ const [endingContent, setEndingContent] = useState<ReactNode | undefined>(undefined);
250
+ // #8848 D8 — the deliverable's action, drawn after the ending screen.
251
+ const tReports = useTranslations("reports");
252
+ const [deliverableContent, setDeliverableContent] = useState<ReactNode | undefined>(undefined);
174
253
 
175
254
  async function handleSubmitStep(
176
255
  payload: FlowStepSubmitPayload,
@@ -185,6 +264,8 @@ export function FlowStepperClient({
185
264
  emailRef.current = mappedEmailOf(config, payload) ?? emailRef.current;
186
265
  if (result.response.completed) {
187
266
  await resolveInviteLink(result.response);
267
+ const delivered = showDeliverable((result.response as { deliverable?: unknown }).deliverable);
268
+ if (endingWins) showEnding(delivered);
188
269
  }
189
270
  } else if (result.kind === "step_conflict") {
190
271
  answersRef.current = {};
@@ -212,6 +293,133 @@ export function FlowStepperClient({
212
293
  }
213
294
  }
214
295
 
296
+ // #8848 D8 — draw the response's deliverable (true when there is one), or,
297
+ // for a gated file walk that minted no grant, the by-email line.
298
+ function showDeliverable(raw: unknown): boolean {
299
+ const deliverable = safeDeliverable(raw);
300
+ if (deliverable !== undefined) {
301
+ setDeliverableContent(deliverableAction(deliverable));
302
+ return true;
303
+ }
304
+ const block = config.deliverable;
305
+ setDeliverableContent(
306
+ raw === undefined && block?.kind === "file" && block.gated ? (
307
+ <Text className="mt-block-tight" data-testid="flow-deliverable-by-email">
308
+ {tReports("fileByEmail")}
309
+ </Text>
310
+ ) : undefined,
311
+ );
312
+ return false;
313
+ }
314
+
315
+ function deliverableAction(deliverable: FlowStepDeliverable): ReactNode {
316
+ const pick = (value: Record<string, string> | undefined): string | undefined =>
317
+ value === undefined ? undefined : resolveLocalizedString(value, locale, config.defaultLocale);
318
+ const label = pick(config.deliverable?.cta);
319
+ if (deliverable.kind === "video") {
320
+ return (
321
+ <div className="mt-block-loose" data-testid="flow-deliverable-video">
322
+ <VideoEmbed
323
+ sources={[{ src: deliverable.src }]}
324
+ {...(deliverable.poster !== undefined ? { poster: deliverable.poster } : {})}
325
+ label={pick(config.deliverable?.title) ?? tReports("openFile")}
326
+ />
327
+ </div>
328
+ );
329
+ }
330
+ const href =
331
+ deliverable.kind === "file"
332
+ ? localeHref(locale, `/reports/${slug}?g=${encodeURIComponent(deliverable.grantId)}`)
333
+ : deliverable.kind === "page"
334
+ ? localeHref(locale, deliverable.href)
335
+ : deliverable.href;
336
+ const fallback = deliverable.kind === "external" ? tReports("openExternal") : tReports("openFile");
337
+ const link = (
338
+ <Button asChild variant="default" className="min-h-11">
339
+ <Link
340
+ href={href}
341
+ variant="bare"
342
+ {...(deliverable.kind === "external" ? { external: true } : {})}
343
+ data-testid="flow-deliverable-action"
344
+ >
345
+ {label ?? fallback}
346
+ </Link>
347
+ </Button>
348
+ );
349
+ // Session Replay records `href`s; the file's carries a bearer grant.
350
+ return deliverable.kind === "file" ? (
351
+ <div data-sentry-block className="mt-block-loose flex justify-center">
352
+ {link}
353
+ </div>
354
+ ) : (
355
+ <div className="mt-block-loose flex justify-center">{link}</div>
356
+ );
357
+ }
358
+
359
+ // #8840 D9 — the ending for the walk's answers: a redirect to a site path,
360
+ // else a screen; a destination the schema refuses is never rendered or
361
+ // pushed (a refused redirect falls back to a screen `default`, else to the
362
+ // stepper's own finished panel). #8848 D8 — with a deliverable in hand a
363
+ // redirect never runs, so the deliverable is never lost to a navigation.
364
+ function showEnding(delivered = false): void {
365
+ const answers = walkAnswers(config, answersRef.current);
366
+ const segmentationNames = segmentationQuestionNames(config);
367
+ const choices: Record<string, string | string[]> = {};
368
+ for (const [name, value] of Object.entries(answers)) {
369
+ if (segmentationNames.has(name) && (typeof value === "string" || Array.isArray(value))) {
370
+ choices[name] = value as string | string[];
371
+ }
372
+ }
373
+ const outcome = selectFlowEndingOutcome(config, choices);
374
+ if (outcome === undefined) return;
375
+ if (outcome.kind === "redirect") {
376
+ if (!delivered && flowSitePathSchema.safeParse(outcome.href).success) {
377
+ setEndingContent(<></>);
378
+ router.push(`/${resolveSafeLocale(locale)}${outcome.href}`);
379
+ return;
380
+ }
381
+ const fallback = config.ending?.default;
382
+ if (fallback?.kind === "screen") setEndingContent(endingScreen(fallback, answers));
383
+ return;
384
+ }
385
+ setEndingContent(endingScreen(outcome, answers));
386
+ }
387
+
388
+ function endingScreen(
389
+ outcome: Extract<FlowEndingOutcome, { kind: "screen" }>,
390
+ answers: Record<string, unknown>,
391
+ ): ReactNode {
392
+ const name = visitorNameOf(config, answers);
393
+ const text = (value: Record<string, string>) =>
394
+ fillEndingName(resolveLocalizedString(value, locale, config.defaultLocale), name);
395
+ const ctas = (outcome.ctas ?? []).filter(
396
+ (cta) => flowSitePathSchema.safeParse(cta.href).success,
397
+ );
398
+ return (
399
+ <div data-testid="flow-ending">
400
+ <Heading level={2}>{text(outcome.headline)}</Heading>
401
+ {outcome.body !== undefined ? (
402
+ <Text className="mt-block-tight">{text(outcome.body)}</Text>
403
+ ) : null}
404
+ {ctas.length > 0 ? (
405
+ <div className="mt-block-loose flex flex-wrap justify-center gap-4">
406
+ {ctas.map((cta) => (
407
+ <Button key={cta.href} asChild variant="default" className="min-h-11">
408
+ <Link
409
+ href={localeHref(locale, cta.href)}
410
+ variant="bare"
411
+ data-testid="flow-ending-cta"
412
+ >
413
+ {text(cta.label)}
414
+ </Link>
415
+ </Button>
416
+ ))}
417
+ </div>
418
+ ) : null}
419
+ </div>
420
+ );
421
+ }
422
+
215
423
  // #4663 D11 — `aeoScore` passes through untouched: this repo's segment
216
424
  // redirect is unchanged; C2's results page (on samhenry) is the consumer
217
425
  // of the score.
@@ -342,7 +550,7 @@ export function FlowStepperClient({
342
550
  // Session Replay records the DOM, so the link (it carries the email) is
343
551
  // blocked from it; `Link` does not forward an undeclared data-* attribute.
344
552
  const completedAction =
345
- inviteHref !== null && thankYou === undefined && !isRecommending ? (
553
+ inviteHref !== null && (endingWins || thankYou === undefined) && !isRecommending ? (
346
554
  <div data-sentry-block>
347
555
  <Button asChild variant="outline" className="mt-4 min-h-11">
348
556
  <Link
@@ -356,16 +564,40 @@ export function FlowStepperClient({
356
564
  </div>
357
565
  ) : undefined;
358
566
 
567
+ // #8848 D8 — the ending screen, then the deliverable; with no ending the
568
+ // stepper's own finished copy heads the action.
569
+ const completedContent =
570
+ deliverableContent !== undefined ? (
571
+ <>
572
+ {endingContent ?? (
573
+ <>
574
+ <Heading level={2}>{t("completedTitle")}</Heading>
575
+ <Text className="mt-block-tight">{t("completedBody")}</Text>
576
+ </>
577
+ )}
578
+ {deliverableContent}
579
+ </>
580
+ ) : (
581
+ endingContent
582
+ );
583
+
359
584
  return (
360
585
  <FlowStepper
361
586
  config={config}
362
587
  locale={locale}
363
588
  messages={messages}
364
589
  onSubmitStep={handleSubmitStep}
365
- {...(thankYou !== undefined || isRecommending
590
+ {...(!endingWins && (thankYou !== undefined || isRecommending)
366
591
  ? { onCompleted: handleCompleted }
367
592
  : {})}
368
593
  {...(completedAction !== undefined ? { completedAction } : {})}
594
+ // #8840 D9 — the locale's policy links, the input-step count, and the
595
+ // page's hidden values, header and ending.
596
+ consentConfig={{ ...SITE_CONSENT_CONFIG_DEFAULTS, policies: consentPoliciesFor(locale) }}
597
+ uncountedStepKeys={config.steps.filter((step) => step.type === "message").map((step) => step.key)}
598
+ {...(hiddenValues !== undefined ? { hiddenValues } : {})}
599
+ {...(header !== undefined ? { header } : {})}
600
+ {...(completedContent !== undefined ? { completedContent } : {})}
369
601
  />
370
602
  );
371
603
  }
@@ -0,0 +1,43 @@
1
+ // oss: false
2
+ /**
3
+ * #8840 D9, AC-3 — unmocked: with the real FlowStepper, a walk of three input
4
+ * steps and a closing message reads "Step 1 of 3" — the closing message is
5
+ * shown but not counted.
6
+ */
7
+ import { render, screen } from "@testing-library/react";
8
+ import type { FlowConfig } from "@working-theory/validation";
9
+
10
+ jest.mock("next/navigation", () => ({
11
+ useRouter: () => ({ push: jest.fn() }),
12
+ }));
13
+
14
+ jest.mock("next-intl", () => ({
15
+ useTranslations: () => (key: string, values?: Record<string, unknown>) =>
16
+ key === "progress" ? `Step ${String(values?.["current"])} of ${String(values?.["total"])}` : key,
17
+ useMessages: () => ({}),
18
+ }));
19
+
20
+ // eslint-disable-next-line no-restricted-syntax -- the boundary under test is the client wiring, not the actions
21
+ jest.mock("../flow.actions", () => ({ submitFlowStepAction: jest.fn() }));
22
+ // eslint-disable-next-line no-restricted-syntax -- see above
23
+ jest.mock("../account-invite.actions", () => ({ accountInviteLinkAction: jest.fn() }));
24
+
25
+ import { FlowStepperClient } from "../FlowStepperClient";
26
+
27
+ const en = (s: string) => ({ en: s });
28
+ const field = (key: string) => ({
29
+ key,
30
+ type: "field" as const,
31
+ copy: { title: en(key) },
32
+ fields: [{ name: key, inputType: "text" as const, copy: { label: en(key) } }],
33
+ });
34
+
35
+ it("counts the three input steps and not the closing message", () => {
36
+ const config = {
37
+ slug: "three",
38
+ defaultLocale: "en",
39
+ steps: [field("one"), field("two"), field("three"), { key: "bye", type: "message", copy: { title: en("Bye") } }],
40
+ } as FlowConfig;
41
+ render(<FlowStepperClient config={config} locale="en" slug="three" />);
42
+ expect(screen.getByTestId("flow-stepper-progress-label").textContent).toBe("Step 1 of 3");
43
+ });
@@ -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
+ });