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
@@ -1,21 +1,324 @@
1
1
  // Pricing page. Reads the Pricing MDX frontmatter, renders sections via
2
2
  // SectionRenderer, and emits a JSON-LD <script>.
3
- import type {
4
- getMdxSource as defaultGetMdxSource,
5
- getPageSections as defaultGetPageSections} from "@working-theory/page-renderer";
3
+ //
4
+ // `?plan=<key>` (#8842) — a diagnostic's recommended plan. The key is handed
5
+ // to every `pricing` section as `recommended`; the section moves its authored
6
+ // recommendation onto the matching plan and scrolls it into view. Reading the
7
+ // query renders this page per request: the recommendation is in the served
8
+ // HTML, so the card never flashes from the authored plan to the named one.
9
+ //
10
+ // The Offerings shown set (#8927, spec
11
+ // docs/specs/8927-pricing-renders-the-shown-set-spec.md). Before rendering,
12
+ // the page reads `GET /v1/content/shown-offerings` through the api-client,
13
+ // shared per server instance for READ_TTL_MS, and renders one of three pages:
14
+ // - unconnected (or production with no API origin) — today's MDX page,
15
+ // exactly as above (D7);
16
+ // - a current shown set — the workspace's Stripe cards in place of the
17
+ // page's authored `pricing` sections, `?plan=` moving the recommendation
18
+ // only for a `{plan}`-grammar value (D4, contract §3.7);
19
+ // - anything else (expired, empty, a failed read) — the site's contact
20
+ // email in place of those sections, never an error (D5).
21
+ // Stripe text reaches markup only as React text and JSON-LD only through
22
+ // renderPage's serializer (T4); the metadata stays the MDX page's (T8).
23
+ import { offerings } from "@working-theory/api-client";
6
24
  import {
25
+ getPageSections as defaultGetPageSections,
7
26
  renderPage,
8
- renderPageMetadata
27
+ renderPageMetadata,
9
28
  } from "@working-theory/page-renderer";
29
+ import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
30
+ import { ContactFallback } from "@working-theory/ui";
31
+ import {
32
+ LOOKUP_KEY_PLAN_PATTERN,
33
+ type PageSection,
34
+ } from "@working-theory/validation";
10
35
  import type { Metadata } from "next";
36
+ import { getTranslations } from "next-intl/server";
11
37
  import type { ReactElement } from "react";
12
38
 
39
+ import {
40
+ offeringsPricing,
41
+ type OfferingsPricing,
42
+ type OfferingsPricingLabels,
43
+ } from "../../../lib/offerings-pricing";
44
+ import { contactFormText } from "../contact/contact-form-text";
45
+ import { readSiteContactEmail } from "../contact/site-contact-email";
46
+
47
+ // #8927 D2 — never prerendered; the Offerings read behind it is shared for READ_TTL_MS.
48
+ export const dynamic = "force-dynamic";
49
+
13
50
  interface Props {
14
51
  params: Promise<{ locale: string }>;
52
+ searchParams?: Promise<Record<string, string | string[] | undefined>>;
15
53
  getMdxSource?: typeof defaultGetMdxSource;
16
54
  getPageSections?: typeof defaultGetPageSections;
17
55
  }
18
56
 
57
+ type PricingSection = Extract<PageSection, { type: "pricing" }>;
58
+ type PricingData = PricingSection["data"];
59
+
60
+ /**
61
+ * Every `pricing` section with `recommended` set to `plan` (#8842). Each
62
+ * section matches the key against its own plans, so a second plan group
63
+ * keeps its own default unless it carries the same key.
64
+ */
65
+ function withRecommendedPlan(
66
+ sections: PageSection[],
67
+ plan: string,
68
+ ): PageSection[] {
69
+ return sections.map((section) =>
70
+ section.type === "pricing"
71
+ ? {
72
+ ...section,
73
+ // `recommended` is a render-time field the section reads, not an
74
+ // authored one, so it is not in the validated section schema.
75
+ data: { ...section.data, recommended: plan } as typeof section.data,
76
+ }
77
+ : section,
78
+ );
79
+ }
80
+
81
+ type OfferingsRead =
82
+ | { kind: "unconnected" }
83
+ | { kind: "shown"; shownSet: unknown }
84
+ | { kind: "contact" };
85
+
86
+ const CONTACT: OfferingsRead = { kind: "contact" };
87
+
88
+ /**
89
+ * How long the Offerings read is shared across requests (D2): a view carries
90
+ * no visitor headers, so every view would otherwise count against this
91
+ * server's one per-IP limit at the API. A product switched off disappears
92
+ * within it. The contact page's window (#8731).
93
+ */
94
+ const READ_TTL_MS = 15_000;
95
+
96
+ const reads = new Map<string, { at: number; read: Promise<OfferingsRead> }>();
97
+
98
+ /**
99
+ * `read()`'s result, shared per instance for READ_TTL_MS. Concurrent calls
100
+ * share one in-flight read, and a failed read is shared the same way, so a
101
+ * burst against an unreachable API is one call.
102
+ */
103
+ function sharedRead(
104
+ key: string,
105
+ read: () => Promise<OfferingsRead>,
106
+ ): Promise<OfferingsRead> {
107
+ const now = Date.now();
108
+ const hit = reads.get(key);
109
+ if (hit && now - hit.at < READ_TTL_MS) return hit.read;
110
+ const fresh = read();
111
+ reads.set(key, { at: now, read: fresh });
112
+ return fresh;
113
+ }
114
+
115
+ /**
116
+ * Whether an API origin is configured: the three names `getApiBase()` in
117
+ * packages/api-client/src/http.ts reads, in step with it. Without one that
118
+ * function answers `localhost:3001`.
119
+ */
120
+ function apiOriginConfigured(): boolean {
121
+ return Boolean(
122
+ process.env.AILK_API_URL ||
123
+ process.env.NEXT_PUBLIC_AILK_API_URL ||
124
+ process.env.NEXT_PUBLIC_API_URL,
125
+ );
126
+ }
127
+
128
+ /**
129
+ * The shown-offerings read (D2). A production deployment with no API has no
130
+ * connection to read, so it is unconnected without a call. The body is
131
+ * untrusted: anything but `unconnected` or `shown` — `empty`, an unknown
132
+ * status, a non-object body, a non-2xx, a throw — is the contact email.
133
+ */
134
+ function readShownOfferings(): Promise<OfferingsRead> {
135
+ if (process.env.NODE_ENV === "production" && !apiOriginConfigured()) {
136
+ return Promise.resolve({ kind: "unconnected" });
137
+ }
138
+ return sharedRead("shown-offerings", async () => {
139
+ try {
140
+ const res = await offerings.shown();
141
+ if (!res.ok) return CONTACT;
142
+ const body: unknown = res.body;
143
+ if (body === null || typeof body !== "object") return CONTACT;
144
+ const { status, shownSet } = body as {
145
+ status?: unknown;
146
+ shownSet?: unknown;
147
+ };
148
+ if (status === "unconnected") return { kind: "unconnected" };
149
+ if (status === "shown") return { kind: "shown", shownSet };
150
+ return CONTACT;
151
+ } catch {
152
+ return CONTACT;
153
+ }
154
+ });
155
+ }
156
+
157
+ /** Keeps the `{amount}` placeholder for `period` to fill (D6). */
158
+ const KEEP_AMOUNT_PLACEHOLDER = { amount: "{amount}" };
159
+
160
+ /** The mapper's labels and the plans section's recommendation label (D6). */
161
+ async function offeringsCopy(locale: string): Promise<{
162
+ labels: OfferingsPricingLabels;
163
+ recommendedLabel: string;
164
+ addonsHeadline: string;
165
+ }> {
166
+ const t = await getTranslations({ locale, namespace: "pricingOfferings" });
167
+ const period = (key: "monthly" | "annual" | "oneTime") => {
168
+ const phrase = t(key, KEEP_AMOUNT_PLACEHOLDER);
169
+ return (amount: string) => phrase.replace("{amount}", () => amount);
170
+ };
171
+ return {
172
+ labels: {
173
+ cta: t("cta"),
174
+ recommended: t("recommended"),
175
+ monthly: period("monthly"),
176
+ annual: period("annual"),
177
+ oneTime: period("oneTime"),
178
+ },
179
+ recommendedLabel: t("recommendedForYou"),
180
+ addonsHeadline: t("addonsHeadline"),
181
+ };
182
+ }
183
+
184
+ /** The authored section copy a Stripe section keeps — never a plan or price fact. */
185
+ const AUTHORED_COPY_FIELDS = [
186
+ "eyebrow",
187
+ "headline",
188
+ "description",
189
+ "header",
190
+ "headlineSize",
191
+ "toggleStyle",
192
+ "featureListStyle",
193
+ "gapAbove",
194
+ "separation",
195
+ "gap",
196
+ "surface",
197
+ "note",
198
+ "collapseFeaturesOnMobile",
199
+ ] as const satisfies readonly (keyof PricingData)[];
200
+
201
+ function authoredCopy(
202
+ section: PricingSection | undefined,
203
+ extra: readonly (keyof PricingData)[] = [],
204
+ ): Partial<PricingData> {
205
+ const copy: Partial<Record<keyof PricingData, unknown>> = {};
206
+ if (!section) return copy as Partial<PricingData>;
207
+ for (const field of [...AUTHORED_COPY_FIELDS, ...extra]) {
208
+ if (section.data[field] !== undefined) copy[field] = section.data[field];
209
+ }
210
+ return copy as Partial<PricingData>;
211
+ }
212
+
213
+ function isPricing(section: PageSection): section is PricingSection {
214
+ return section.type === "pricing";
215
+ }
216
+
217
+ /** Where a section with no slot goes: after a leading hero, else first. */
218
+ function insertionIndex(sections: PageSection[]): number {
219
+ return sections[0]?.type === "hero" ? 1 : 0;
220
+ }
221
+
222
+ /**
223
+ * The page's sections with the Stripe fragments in place of its authored
224
+ * `pricing` sections (D4). The first non-`addons` pricing section is the
225
+ * plans slot and the first `addons` one the add-ons slot; every other pricing
226
+ * section, and a slot whose fragment is `null`, is removed. A fragment with no
227
+ * slot is inserted after a leading hero (the add-ons directly after the
228
+ * plans). No other section moves.
229
+ */
230
+ function withOfferings(
231
+ sections: PageSection[],
232
+ pricing: OfferingsPricing,
233
+ plan: string | undefined,
234
+ recommendedLabel: string,
235
+ addonsHeadline: string,
236
+ ): PageSection[] {
237
+ const plansSlot = sections.find(
238
+ (s): s is PricingSection => isPricing(s) && s.data.variant !== "addons",
239
+ );
240
+ const addonsSlot = sections.find(
241
+ (s): s is PricingSection => isPricing(s) && s.data.variant === "addons",
242
+ );
243
+ const recommend =
244
+ plan !== undefined && LOOKUP_KEY_PLAN_PATTERN.test(plan) ? plan : undefined;
245
+ const plansSection: PricingSection | null = pricing.plans && {
246
+ id: plansSlot?.id ?? "offerings-plans",
247
+ type: "pricing",
248
+ data: {
249
+ ...authoredCopy(plansSlot),
250
+ ...pricing.plans,
251
+ // A `?plan=` recommendation moves the mapper's highlight onto the named
252
+ // plan and wears `recommendedLabel` (#8842); the mapper's own
253
+ // "Recommended" pill label would ride along as a second label, so it is
254
+ // dropped from every card when a recommendation is in play.
255
+ ...(recommend !== undefined &&
256
+ pricing.plans.plans.some(
257
+ (card) => card.key?.toLowerCase() === recommend.toLowerCase(),
258
+ )
259
+ ? {
260
+ plans: pricing.plans.plans.map(({ highlightLabel: _, ...card }) => card),
261
+ }
262
+ : {}),
263
+ // Render-time fields the section reads (#8842), not authored ones.
264
+ recommendedLabel,
265
+ ...(recommend !== undefined ? { recommended: recommend } : {}),
266
+ } as PricingData,
267
+ };
268
+ const addonsSection: PricingSection | null = pricing.addons && {
269
+ id: addonsSlot?.id ?? "offerings-addons",
270
+ type: "pricing",
271
+ data: {
272
+ // An inserted add-ons section (no authored slot) sits tight under the
273
+ // plans and carries its own headline, so a lone tile is never an
274
+ // unlabeled band (design review B2).
275
+ ...(addonsSlot ? {} : { headline: addonsHeadline, gapAbove: "tight" }),
276
+ ...authoredCopy(addonsSlot, ["addonLayout"]),
277
+ ...pricing.addons,
278
+ } as PricingData,
279
+ };
280
+
281
+ const placed: PageSection[] = [];
282
+ for (const section of sections) {
283
+ if (!isPricing(section)) placed.push(section);
284
+ else if (section === plansSlot && plansSection) placed.push(plansSection);
285
+ else if (section === addonsSlot && addonsSection)
286
+ placed.push(addonsSection);
287
+ }
288
+ if (plansSection && !plansSlot) {
289
+ placed.splice(insertionIndex(placed), 0, plansSection);
290
+ }
291
+ if (addonsSection && !addonsSlot) {
292
+ const plansAt = plansSection ? placed.indexOf(plansSection) : -1;
293
+ placed.splice(
294
+ plansAt >= 0 ? plansAt + 1 : insertionIndex(placed),
295
+ 0,
296
+ addonsSection,
297
+ );
298
+ }
299
+ return placed;
300
+ }
301
+
302
+ /** The section the contact email is slotted into (D5). */
303
+ const CONTACT_SECTION_ID = "offerings-contact";
304
+
305
+ /**
306
+ * The page's sections with every `pricing` section removed and one
307
+ * `contact-form` section in the first one's place, else after a leading hero
308
+ * (D5). It contributes no JSON-LD, so the page publishes no `Offer`.
309
+ */
310
+ function withContactFallback(sections: PageSection[]): PageSection[] {
311
+ const first = sections.findIndex(isPricing);
312
+ const kept: PageSection[] = sections.filter((s) => !isPricing(s));
313
+ const contact = {
314
+ id: CONTACT_SECTION_ID,
315
+ type: "contact-form",
316
+ data: { fields: [] },
317
+ } as PageSection;
318
+ kept.splice(first >= 0 ? first : insertionIndex(kept), 0, contact);
319
+ return kept;
320
+ }
321
+
19
322
  export async function generateMetadata({
20
323
  params,
21
324
  }: Pick<Props, "params">): Promise<Metadata> {
@@ -25,16 +328,74 @@ export async function generateMetadata({
25
328
 
26
329
  export default async function Page({
27
330
  params,
331
+ searchParams,
28
332
  getMdxSource,
29
- getPageSections,
333
+ getPageSections = defaultGetPageSections,
30
334
  }: Props): Promise<ReactElement> {
31
335
  const { locale } = await params;
336
+ const search = (await searchParams) ?? {};
337
+ const plan = Array.isArray(search.plan) ? search.plan[0] : search.plan;
338
+ const read = await readShownOfferings();
339
+
340
+ if (read.kind === "unconnected") {
341
+ return renderPage({
342
+ type: "Pricing",
343
+ slug: "pricing",
344
+ locale,
345
+ translationsNamespace: "pricing",
346
+ ...(getMdxSource !== undefined && { getMdxSource }),
347
+ getPageSections: plan
348
+ ? async (frontmatter) =>
349
+ withRecommendedPlan(await getPageSections(frontmatter), plan)
350
+ : getPageSections,
351
+ });
352
+ }
353
+
354
+ if (read.kind === "shown") {
355
+ const { labels, recommendedLabel, addonsHeadline } =
356
+ await offeringsCopy(locale);
357
+ const pricing = offeringsPricing(read.shownSet, { locale, labels });
358
+ if (pricing && (pricing.plans || pricing.addons)) {
359
+ return renderPage({
360
+ type: "Pricing",
361
+ slug: "pricing",
362
+ locale,
363
+ translationsNamespace: "pricing",
364
+ ...(getMdxSource !== undefined && { getMdxSource }),
365
+ getPageSections: async (frontmatter) =>
366
+ withOfferings(
367
+ await getPageSections(frontmatter),
368
+ pricing,
369
+ plan,
370
+ recommendedLabel,
371
+ addonsHeadline,
372
+ ),
373
+ });
374
+ }
375
+ }
376
+
377
+ const text = contactFormText(
378
+ await getTranslations({ locale, namespace: "contact" }),
379
+ );
32
380
  return renderPage({
33
381
  type: "Pricing",
34
382
  slug: "pricing",
35
383
  locale,
36
384
  translationsNamespace: "pricing",
37
385
  ...(getMdxSource !== undefined && { getMdxSource }),
38
- ...(getPageSections !== undefined && { getPageSections }),
386
+ getPageSections: async (frontmatter) =>
387
+ withContactFallback(await getPageSections(frontmatter)),
388
+ sectionSlots: {
389
+ [CONTACT_SECTION_ID]: (
390
+ <ContactFallback
391
+ email={readSiteContactEmail()}
392
+ text={{
393
+ heading: text.unavailableHeading,
394
+ body: text.unavailableBody,
395
+ noEmail: text.unavailableNoEmail,
396
+ }}
397
+ />
398
+ ),
399
+ },
39
400
  });
40
401
  }
@@ -68,7 +68,10 @@ describe("team/[slug] — the profile page", () => {
68
68
  const person = profile["mainEntity"] as JsonLd;
69
69
  expect(person["@type"]).toBe("Person");
70
70
  expect(person["@id"]).toBe("#person");
71
- expect(person["jobTitle"]).toBe(FIRST_MEMBER.frontmatter.schema.jobTitle);
71
+ // The seed's `jobTitle` is a `{placeholder}` slot, which never reaches the
72
+ // graph (#8797 D8.5).
73
+ expect(FIRST_MEMBER.frontmatter.schema.jobTitle).toBe("{Their title}");
74
+ expect(person).not.toHaveProperty("jobTitle");
72
75
 
73
76
  const breadcrumb = graph.find((b) => b["@type"] === "BreadcrumbList")!;
74
77
  const items = breadcrumb["itemListElement"] as Array<{ item: string }>;
@@ -139,6 +139,19 @@ for (const specifier of ["next/link", "next/image", "next/script"]) {
139
139
  }
140
140
  // `next-intl` is path-independent: jest.config.cjs maps every copy onto one stub.
141
141
  jest.doMock("next-intl", () => flight.createClientModuleProxy("next-intl"));
142
+ // #8847 D12 — the page reads the newsletter magnet through
143
+ // `lib/in-page-magnet`, whose module graph reaches `contact.actions`' import
144
+ // of `next/navigation`. Next serves server code that entry's react-server
145
+ // build; under jest it is the client build, which needs a React context, so
146
+ // it is stubbed as `signed-in-nav-rsc-boundary.test.tsx` stubs it.
147
+ jest.doMock("next/navigation", () => ({
148
+ redirect: () => {
149
+ throw new Error("redirect");
150
+ },
151
+ notFound: () => {
152
+ throw new Error("NEXT_NOT_FOUND");
153
+ },
154
+ }));
142
155
 
143
156
  // ── Render ───────────────────────────────────────────────────────────────────
144
157
 
@@ -200,6 +213,10 @@ describe("/[locale]/unsubscribe crosses the RSC → client boundary cleanly", ()
200
213
  const { errors, payload } = await renderPageToFlight("tok_1");
201
214
  expect(errors).toEqual([]);
202
215
  expect(payload).toContain("unsubscribe-page");
216
+ // #8847 D12 — the island gets the token and the magnet's copy as strings.
217
+ const props = /"token":"tok_1"[^}]*}/.exec(payload)?.[0] ?? "";
218
+ expect(props).toContain('"cta":"Unsubscribe"');
219
+ expect(props).toContain('"doneHeadline":"You\'re unsubscribed."');
203
220
  });
204
221
 
205
222
  it("serializes with no token", async () => {
@@ -40,9 +40,11 @@ jest.mock("next-intl", () => ({
40
40
  }));
41
41
 
42
42
  const mockUnsubscribe = jest.fn();
43
+ const mockFlowsGet = jest.fn();
43
44
  jest.mock("@working-theory/api-client", () => ({
44
45
  __esModule: true,
45
46
  consent: { unsubscribe: (...args: unknown[]) => mockUnsubscribe(...args) },
47
+ flows: { get: (...args: unknown[]) => mockFlowsGet(...args) },
46
48
  visitorForwardHeaders: () => undefined,
47
49
  }));
48
50
 
@@ -56,8 +58,16 @@ import Page, { generateMetadata } from "../page";
56
58
  const ok = <T,>(body: T) => ({ ok: true, body });
57
59
  const refusal = (status: number) => ({ ok: false, status, body: null });
58
60
 
61
+ // #8847 D12 — the page's newsletter-magnet read is shared for a few seconds
62
+ // per module instance; each test starts an hour later so none sees another's.
63
+ let clock = 1_900_000_000_000;
64
+ jest.spyOn(Date, "now").mockImplementation(() => clock);
65
+
59
66
  beforeEach(() => {
67
+ clock += 60 * 60 * 1000;
60
68
  mockUnsubscribe.mockReset();
69
+ // The hub unreadable: the seeded newsletterFlowConfig's unsubscribe copy.
70
+ mockFlowsGet.mockReset().mockRejectedValue(new Error("hub down"));
61
71
  });
62
72
 
63
73
  async function renderPage(token: string | undefined): Promise<void> {
@@ -86,7 +96,7 @@ describe("the page performs no write on GET", () => {
86
96
  expect(mockUnsubscribe).not.toHaveBeenCalled();
87
97
  expect(screen.getByTestId("unsubscribe-page")).toBeInTheDocument();
88
98
  expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Unsubscribe");
89
- expect(screen.getByText("Stop these emails?")).toBeInTheDocument();
99
+ expect(screen.getByText("Stop getting the newsletter by email?")).toBeInTheDocument();
90
100
  });
91
101
  });
92
102
 
@@ -100,7 +110,7 @@ describe("with no token", () => {
100
110
  });
101
111
 
102
112
  describe("clicking Unsubscribe", () => {
103
- it("a changed answer shows the done line with the purpose's description", async () => {
113
+ it("a changed answer shows the magnet's done message (#8847 D12)", async () => {
104
114
  mockUnsubscribe.mockResolvedValue(
105
115
  ok({ purpose: { id: "marketing_email", description: "product news and offers" }, changed: true }),
106
116
  );
@@ -108,7 +118,7 @@ describe("clicking Unsubscribe", () => {
108
118
  await userEvent.click(screen.getByTestId("unsubscribe-button"));
109
119
  expect(mockUnsubscribe).toHaveBeenCalledWith({ token: "tok_1" });
110
120
  expect(await screen.findByTestId("unsubscribe-done")).toHaveTextContent(
111
- "You're unsubscribed from product news and offers.",
121
+ "You're unsubscribed.You won't get the newsletter any more.",
112
122
  );
113
123
  });
114
124
 
@@ -156,3 +166,52 @@ describe("clicking Unsubscribe", () => {
156
166
  expect(await screen.findByTestId("unsubscribe-unavailable")).toBeInTheDocument();
157
167
  });
158
168
  });
169
+
170
+ describe("the copy comes from the newsletter magnet (#8847 D12, AC-7)", () => {
171
+ const unsubscribe = {
172
+ title: { en: "Leave" },
173
+ body: { en: "Sure?" },
174
+ cta: { en: "Leave now" },
175
+ done: { headline: { en: "Gone." } },
176
+ };
177
+
178
+ beforeEach(() => {
179
+ const { newsletterFlowConfig } = jest.requireActual("@working-theory/validation") as {
180
+ newsletterFlowConfig: Record<string, unknown>;
181
+ };
182
+ const { completionEmail: _omitted, ...body } = newsletterFlowConfig;
183
+ mockFlowsGet.mockReset().mockResolvedValue({ ok: true, status: 200, body: { ...body, unsubscribe } });
184
+ });
185
+
186
+ it("the title, line and button are the served block's, and so is the done message", async () => {
187
+ mockUnsubscribe.mockResolvedValue(
188
+ ok({ purpose: { id: "newsletter", description: "This site's newsletter by email" }, changed: true }),
189
+ );
190
+ await renderPage("tok_1");
191
+ expect(mockFlowsGet).toHaveBeenCalledWith("newsletter");
192
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Leave");
193
+ expect(screen.getByText("Sure?")).toBeInTheDocument();
194
+ expect(screen.getByTestId("unsubscribe-button")).toHaveTextContent("Leave now");
195
+ await userEvent.click(screen.getByTestId("unsubscribe-button"));
196
+ expect(await screen.findByTestId("unsubscribe-done")).toHaveTextContent("Gone.");
197
+ });
198
+
199
+ it("generateMetadata's title is the served block's", async () => {
200
+ const meta = await generateMetadata({
201
+ params: Promise.resolve({ locale: "en" }),
202
+ searchParams: Promise.resolve({}),
203
+ });
204
+ expect(meta.title).toBe("Leave");
205
+ });
206
+
207
+ it("already still shows the catalog's line", async () => {
208
+ mockUnsubscribe.mockResolvedValue(
209
+ ok({ purpose: { id: "newsletter", description: "This site's newsletter by email" }, changed: false }),
210
+ );
211
+ await renderPage("tok_1");
212
+ await userEvent.click(screen.getByTestId("unsubscribe-button"));
213
+ expect(await screen.findByTestId("unsubscribe-already")).toHaveTextContent(
214
+ "You were already unsubscribed.",
215
+ );
216
+ });
217
+ });
@@ -4,32 +4,73 @@
4
4
  * `?t=` token never leaks through a Referer header off this page (D17,
5
5
  * SEC-8438-9).
6
6
  *
7
- * RSC: reads `?t=` and renders the heading, the lead and the one
7
+ * RSC: reads `?t=` and renders the heading, the line and the one
8
8
  * interactive piece (`UnsubscribeButton`, "use client") that posts
9
9
  * `unsubscribeAction(token)` ON CLICK — never on this GET, so a mail
10
- * scanner's prefetch writes nothing. The island gets the token and nothing
11
- * else: it reads its own strings, because anything callable handed across
12
- * this server→client edge fails Flight serialization (the scaffold matrix's
13
- * `GET /unsubscribe -> 500`; `__tests__/unsubscribe-rsc-boundary.test.tsx`).
10
+ * scanner's prefetch writes nothing. The island gets the token and three
11
+ * strings: anything callable handed across this server→client edge fails
12
+ * Flight serialization (the scaffold matrix's `GET /unsubscribe -> 500`;
13
+ * `__tests__/unsubscribe-rsc-boundary.test.tsx`).
14
+ *
15
+ * #8847 D12 — the title, line, button and done message come from the
16
+ * newsletter magnet's `unsubscribe` block (`readServedMagnet`, shared like
17
+ * /contact's read), else the seeded `newsletterFlowConfig`'s. The token states
18
+ * (already, invalid, unavailable) keep their catalog lines: they describe
19
+ * the link, not the newsletter. The frame is /contact's `column` look. The
20
+ * page renders on every request.
14
21
  */
15
22
 
23
+ import { SectionFrame } from "@working-theory/ui";
16
24
  import { Heading, Text } from "@working-theory/ui/primitives";
25
+ import {
26
+ NEWSLETTER_FLOW_SLUG,
27
+ newsletterFlowConfig,
28
+ resolveLocalizedString,
29
+ type FlowUnsubscribeCopy,
30
+ } from "@working-theory/validation";
17
31
  import type { Metadata } from "next";
18
- import { getTranslations } from "next-intl/server";
19
32
  import type { ReactElement } from "react";
20
33
 
34
+ import { readServedMagnet } from "../../../lib/in-page-magnet";
35
+
21
36
  import { UnsubscribeButton } from "./unsubscribe-button";
22
37
 
38
+ export const dynamic = "force-dynamic";
39
+
23
40
  interface Props {
24
41
  params: Promise<{ locale: string }>;
25
42
  searchParams: Promise<{ t?: string }>;
26
43
  }
27
44
 
45
+ interface UnsubscribeCopy {
46
+ title: string;
47
+ body?: string;
48
+ cta: string;
49
+ doneHeadline: string;
50
+ doneBody?: string;
51
+ }
52
+
53
+ /** The newsletter magnet's unsubscribe copy, else the seeded config's. */
54
+ async function unsubscribeCopy(locale: string): Promise<UnsubscribeCopy> {
55
+ const served = await readServedMagnet(NEWSLETTER_FLOW_SLUG);
56
+ const config = served?.unsubscribe ? served : newsletterFlowConfig;
57
+ const block = config.unsubscribe as FlowUnsubscribeCopy;
58
+ const resolve = (value: Record<string, string>): string =>
59
+ resolveLocalizedString(value, locale, config.defaultLocale);
60
+ return {
61
+ title: resolve(block.title),
62
+ ...(block.body !== undefined && { body: resolve(block.body) }),
63
+ cta: resolve(block.cta),
64
+ doneHeadline: resolve(block.done.headline),
65
+ ...(block.done.body !== undefined && { doneBody: resolve(block.done.body) }),
66
+ };
67
+ }
68
+
28
69
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
29
70
  const { locale } = await params;
30
- const t = await getTranslations({ locale, namespace: "unsubscribe" });
71
+ const copy = await unsubscribeCopy(locale);
31
72
  return {
32
- title: t("title"),
73
+ title: copy.title,
33
74
  robots: { index: false, follow: true },
34
75
  referrer: "no-referrer",
35
76
  };
@@ -38,17 +79,28 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
38
79
  export default async function UnsubscribePage({ params, searchParams }: Props): Promise<ReactElement> {
39
80
  const { locale } = await params;
40
81
  const { t: token } = await searchParams;
41
- const t = await getTranslations({ locale, namespace: "unsubscribe" });
82
+ const copy = await unsubscribeCopy(locale);
42
83
 
43
84
  return (
44
- <div className="mx-auto flex max-w-md flex-col gap-4 px-4 py-16" data-testid="unsubscribe-page">
45
- <Heading level={1} visual={2}>
46
- {t("title")}
47
- </Heading>
48
- <Text as="p" variant="body">
49
- {t("lead")}
50
- </Text>
51
- <UnsubscribeButton token={token ?? null} />
52
- </div>
85
+ <SectionFrame sectionId="unsubscribe" className="ailk-contact-frame" data-testid="unsubscribe-page">
86
+ <div className="ailk-contact-column text-center">
87
+ <Heading level={1} visual="display" className="ailk-contact-heading text-center">
88
+ {copy.title}
89
+ </Heading>
90
+ {copy.body !== undefined && (
91
+ <Text as="p" tone="muted" className="mx-auto mt-block-tight ailk-contact-measure text-balance">
92
+ {copy.body}
93
+ </Text>
94
+ )}
95
+ <div className="ailk-contact-slot mx-auto mt-block-loose flex w-full ailk-contact-measure flex-col items-center">
96
+ <UnsubscribeButton
97
+ token={token ?? null}
98
+ cta={copy.cta}
99
+ doneHeadline={copy.doneHeadline}
100
+ {...(copy.doneBody !== undefined && { doneBody: copy.doneBody })}
101
+ />
102
+ </div>
103
+ </div>
104
+ </SectionFrame>
53
105
  );
54
106
  }