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,101 +1,104 @@
1
1
  /**
2
- * newsletter/page.tsx — a signup only when there is somewhere to keep it
3
- * (#6730 D9).
2
+ * newsletter/page.tsx — the newsletter magnet is the only source (#8847 D11,
3
+ * AC-6), and a signup only when there is somewhere to keep it (#6730 D9).
4
4
  *
5
- * Boundaries mocked: `@working-theory/ui` (the section tree is a stub that
6
- * renders one element per section, with a `newsletter` section rendering the
7
- * form its content declares), `@working-theory/api-client`
8
- * (`leads.captureStatus`), `next-intl/server`, and the Server Action module.
5
+ * Renders the REAL `NewsletterFormInline` and binds the REAL
6
+ * `subscribeNewsletterAction`. Mocked boundaries: `@working-theory/api-client`
7
+ * (`flows.get`, `flows.submitStep`, `leads.captureStatus`, `leads.subscribe`),
8
+ * `next/headers`, `next-intl/server` (over the real English catalog), the
9
+ * `_site.mdx` contact-email read, and the page-renderer's content loaders
10
+ * (injected).
9
11
  */
10
12
  import React from "react";
11
- import { render, screen } from "@testing-library/react";
12
-
13
- jest.mock("@working-theory/ui", () => ({
14
- // #7789: renderPage's fallback page head renders inside the section Container.
15
- Container: ({ children }: { children?: React.ReactNode }) =>
16
- React.createElement("div", null, children),
17
- Heading: ({ children, level }: { children: React.ReactNode; level: number }) =>
18
- React.createElement(`h${level}`, null, children),
19
- SectionRenderer: ({
20
- sections,
21
- }: {
22
- sections: Array<{
23
- id: string;
24
- type: string;
25
- data: { action?: string; primaryCta?: { href: string } };
26
- }>;
27
- }) =>
28
- React.createElement(
29
- "div",
30
- { "data-testid": "section-renderer" },
31
- sections.map((s) =>
32
- s.type === "newsletter"
33
- ? React.createElement(
34
- "form",
35
- { key: s.id, "data-section-id": s.id, action: s.data.action },
36
- "signup",
37
- )
38
- : React.createElement(
39
- "div",
40
- { key: s.id, "data-section-id": s.id },
41
- s.data.primaryCta
42
- ? React.createElement("a", { href: s.data.primaryCta.href }, "cta")
43
- : null,
44
- ),
45
- ),
46
- ),
47
- localeHref: (locale: string, path: string) => `/${locale}${path}`,
48
- LeadCaptureForm: (props: { fields?: Array<{ name: string; type: string }> }) =>
49
- React.createElement("div", {
50
- "data-testid": "lead-capture-form",
51
- "data-fields": (props.fields ?? []).map((f) => `${f.name}:${f.type}`).join(","),
52
- }),
13
+ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
14
+ import { newsletterFlowConfig, type FlowConfig } from "@working-theory/validation";
15
+
16
+ jest.mock("next/navigation", () => ({
17
+ __esModule: true,
18
+ notFound: jest.fn(() => {
19
+ throw new Error("NEXT_NOT_FOUND");
20
+ }),
21
+ }));
22
+
23
+ jest.mock("next-mdx-remote/rsc", () => ({
24
+ __esModule: true,
25
+ MDXRemote: ({ source }: { source: string }) =>
26
+ React.createElement("div", { "data-testid": "mdx-rendered" }, source),
53
27
  }));
54
28
 
55
29
  jest.mock("@working-theory/api-client", () => ({
56
- leads: { captureStatus: jest.fn() },
30
+ flows: { get: jest.fn(), submitStep: jest.fn() },
31
+ leads: { captureStatus: jest.fn(), submit: jest.fn(), subscribe: jest.fn() },
32
+ visitorForwardHeaders: () => undefined,
57
33
  }));
58
34
 
59
- jest.mock("next-intl/server", () => ({
60
- getTranslations: async () => (key: string) => key,
35
+ jest.mock("next/headers", () => ({
36
+ __esModule: true,
37
+ headers: jest.fn().mockResolvedValue({ get: () => null }),
38
+ cookies: jest.fn().mockResolvedValue({ get: () => undefined }),
61
39
  }));
62
40
 
63
- // eslint-disable-next-line no-restricted-syntax -- test-seam mock: the Server Action module is a "use server" boundary; the page only passes it through
64
- jest.mock("../newsletter.actions", () => ({
65
- subscribeNewsletterAction: jest.fn(),
41
+ // The real English catalog, so the page's own strings render as they ship.
42
+ jest.mock("next-intl/server", () => {
43
+ const messages = jest.requireActual("../../../../messages/en.json") as Record<
44
+ string,
45
+ Record<string, unknown>
46
+ >;
47
+ return {
48
+ getLocale: async () => "en",
49
+ getTranslations: async (arg: string | { namespace: string }) => {
50
+ const ns = messages[typeof arg === "string" ? arg : arg.namespace] ?? {};
51
+ return (key: string, values?: Record<string, string>) =>
52
+ String(ns[key] ?? key).replace(/\{(\w+)\}/g, (m, name: string) => values?.[name] ?? m);
53
+ },
54
+ };
55
+ });
56
+
57
+ // eslint-disable-next-line no-restricted-syntax -- test-seam mock: the real read is a node:fs frontmatter parse of content/_site.mdx; narrowest possible scope
58
+ jest.mock("../../contact/site-contact-email", () => ({
59
+ readSiteContactEmail: jest.fn().mockReturnValue("hi@x.co"),
66
60
  }));
67
61
 
68
- import { leads } from "@working-theory/api-client";
62
+ import { flows, leads } from "@working-theory/api-client";
69
63
 
64
+ import * as pageModule from "../page";
70
65
  import NewsletterPage from "../page";
71
66
 
67
+ const mockFlowsGet = flows.get as jest.Mock;
68
+ const mockSubmitStep = flows.submitStep as jest.Mock;
72
69
  const mockCaptureStatus = leads.captureStatus as jest.Mock;
70
+
73
71
  const status = (newsletter: boolean) => ({
74
72
  ok: true,
75
73
  status: 200,
76
74
  body: { ok: true, data: { contact: true, newsletter } },
77
75
  });
78
76
 
77
+ /** The served magnet: the stored config minus its completion email. */
78
+ function served(config: FlowConfig) {
79
+ const { completionEmail: _omitted, ...body } = config;
80
+ return { ok: true, status: 200, body };
81
+ }
82
+
79
83
  const source = {
80
84
  frontmatter: {
81
85
  type: "NewsletterSignup" as const,
82
86
  slug: "newsletter",
83
- title: "Newsletter",
87
+ title: "{Newsletter}",
84
88
  description: "What subscribers get.",
85
89
  },
86
90
  body: "",
87
91
  };
88
92
 
89
- /** `_base`'s content: a hero whose CTA points at `#signup`, the signup section, an FAQ. */
90
- const BASE_SECTIONS = [
91
- { id: "hero", type: "hero", data: { headline: "News", primaryCta: { label: "Subscribe", href: "#signup" } } },
92
- { id: "signup", type: "newsletter", data: { action: "/v1/leads/subscribe", source: "newsletter" } },
93
- { id: "faq", type: "faq", data: { items: [] } },
93
+ /** The starter's seed (#8847 D14): one column contact-form section. */
94
+ const STARTER_SECTIONS = [
95
+ { id: "signup", type: "contact-form", data: { variant: "column", fields: [] } },
94
96
  ];
95
97
 
96
- /** #6738's Free content: no signup section. */
97
- const FREE_SECTIONS = [
98
- { id: "hero", type: "hero", data: { headline: "News" } },
98
+ /** `_base`'s pre-#8847 content: a hero whose CTA points at `#signup`, the signup section, an FAQ. */
99
+ const LEGACY_SECTIONS = [
100
+ { id: "hero", type: "hero", data: { headline: "News", primaryCta: { label: "Subscribe", href: "#signup" } } },
101
+ { id: "signup", type: "newsletter", data: { action: "/v1/leads/subscribe", source: "newsletter" } },
99
102
  { id: "faq", type: "faq", data: { items: [] } },
100
103
  ];
101
104
 
@@ -108,59 +111,126 @@ async function renderWith(sections: unknown[]) {
108
111
  return render(element);
109
112
  }
110
113
 
111
- beforeEach(() => jest.clearAllMocks());
114
+ // The page shares its hub reads for a few seconds, per module instance. Each
115
+ // test starts an hour later so none sees another's read.
116
+ let clock = 1_900_000_000_000;
117
+ jest.spyOn(Date, "now").mockImplementation(() => clock);
112
118
 
113
- describe("NewsletterPage — #6730 D9", () => {
114
- it("newsletter:true + no newsletter section → one appended form with a single email field", async () => {
115
- mockCaptureStatus.mockResolvedValue(status(true));
119
+ beforeEach(() => {
120
+ jest.clearAllMocks();
121
+ clock += 60 * 60 * 1000;
122
+ mockCaptureStatus.mockResolvedValue(status(true));
123
+ mockFlowsGet.mockResolvedValue(served(newsletterFlowConfig));
124
+ });
116
125
 
117
- await renderWith(FREE_SECTIONS);
126
+ describe("NewsletterPage — the newsletter magnet (#8847 AC-6)", () => {
127
+ it("is rendered on every request, never ISR", () => {
128
+ expect(pageModule.dynamic).toBe("force-dynamic");
129
+ expect("revalidate" in pageModule).toBe(false);
130
+ });
131
+
132
+ it("the H1, label, consent and help line are the magnet's; no upsell", async () => {
133
+ await renderWith(STARTER_SECTIONS);
134
+
135
+ expect(mockFlowsGet).toHaveBeenCalledWith("newsletter");
136
+ const headings = screen.getAllByRole("heading", { level: 1 });
137
+ expect(headings).toHaveLength(1);
138
+ expect(headings[0]).toHaveTextContent("Get the newsletter");
139
+ expect(document.body).toHaveTextContent("One email when there is something new.");
140
+ expect(screen.getByText("Email")).not.toHaveClass("sr-only");
141
+ const statement = screen.getByTestId("newsletter-consent-statement");
142
+ expect(statement).toHaveTextContent("I accept the Terms and Privacy Policy.");
143
+ expect(statement.querySelector("a[href='/terms']")).not.toBeNull();
144
+ expect(statement.querySelector("a[href='/privacy']")).not.toBeNull();
145
+ expect(screen.getByTestId("newsletter-help")).toHaveTextContent("No spam. Unsubscribe any time.");
146
+ expect(screen.getByTestId("newsletter-submit")).toHaveTextContent("Subscribe");
147
+ for (const upsell of ["Substack", "Pro", "ailaunchkit.ai"]) {
148
+ expect(document.body).not.toHaveTextContent(upsell);
149
+ }
150
+ });
118
151
 
119
- expect(screen.getAllByTestId("lead-capture-form")).toHaveLength(1);
120
- expect(screen.getByTestId("lead-capture-form")).toHaveAttribute(
121
- "data-fields",
122
- "email:email",
152
+ it("generateMetadata's title is the magnet's step title", async () => {
153
+ const metadata = await pageModule.generateMetadata({
154
+ params: Promise.resolve({ locale: "en" }),
155
+ getMdxSource: jest.fn().mockResolvedValue(source),
156
+ getPageSections: jest.fn().mockResolvedValue(STARTER_SECTIONS),
157
+ });
158
+ expect(String((metadata.title as { absolute?: string })?.absolute ?? metadata.title)).toContain(
159
+ "Get the newsletter",
123
160
  );
124
161
  });
125
162
 
126
- it("newsletter:false + no newsletter section → no form at all", async () => {
163
+ it("a signup goes through the magnet and the ending replaces the form", async () => {
164
+ mockSubmitStep.mockResolvedValue({
165
+ ok: true,
166
+ status: 200,
167
+ body: { sessionId: "s1", nextStepKey: null, completed: true },
168
+ });
169
+
170
+ await renderWith(STARTER_SECTIONS);
171
+ fireEvent.change(screen.getByTestId("newsletter-email-input"), { target: { value: "a@b.co" } });
172
+ fireEvent.click(screen.getByTestId("newsletter-consent-checkbox"));
173
+ await act(async () => {
174
+ fireEvent.submit(screen.getByTestId("newsletter-form"));
175
+ });
176
+
177
+ await waitFor(() =>
178
+ expect(screen.getByTestId("newsletter-success-headline")).toHaveTextContent("Check your inbox"),
179
+ );
180
+ expect(mockSubmitStep).toHaveBeenCalledWith("newsletter", {
181
+ stepKey: "subscribe",
182
+ values: { email: "a@b.co", "terms.newsletter": true },
183
+ locale: "en",
184
+ });
185
+ expect(leads.subscribe).not.toHaveBeenCalled();
186
+ });
187
+
188
+ it("with the hub unreadable, the seeded config renders", async () => {
189
+ mockFlowsGet.mockRejectedValue(new Error("hub down"));
190
+
191
+ await renderWith(STARTER_SECTIONS);
192
+
193
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Get the newsletter");
194
+ expect(screen.getByTestId("newsletter-form")).toBeInTheDocument();
195
+ });
196
+
197
+ it("with capture off, ContactFallback with the _site.mdx email renders and no newsletter form", async () => {
127
198
  mockCaptureStatus.mockResolvedValue(status(false));
128
199
 
129
- const { container } = await renderWith(FREE_SECTIONS);
200
+ await renderWith(STARTER_SECTIONS);
130
201
 
131
- expect(screen.queryByTestId("lead-capture-form")).toBeNull();
132
- expect(container.querySelector("form")).toBeNull();
202
+ expect(screen.queryByTestId("newsletter-form")).toBeNull();
203
+ expect(document.querySelector("a[href='mailto:hi@x.co']")).not.toBeNull();
133
204
  });
134
205
 
135
- it("newsletter:true + _base's newsletter section → that section renders, no second form", async () => {
136
- mockCaptureStatus.mockResolvedValue(status(true));
206
+ it("an authored newsletter section never renders beside the slot (one signup form)", async () => {
207
+ await renderWith([...STARTER_SECTIONS, LEGACY_SECTIONS[1]]);
208
+
209
+ expect(screen.getAllByTestId("newsletter-form")).toHaveLength(1);
210
+ expect(document.querySelector("form[action='/v1/leads/subscribe']")).toBeNull();
211
+ });
212
+ });
137
213
 
138
- const { container } = await renderWith(BASE_SECTIONS);
214
+ describe("NewsletterPage — a page with no contact-form section keeps its composition (#6730 D9)", () => {
215
+ it("newsletter:true + its own newsletter section → that section renders, no second form", async () => {
216
+ await renderWith(LEGACY_SECTIONS);
139
217
 
140
- expect(container.querySelector("form[action='/v1/leads/subscribe']")).not.toBeNull();
141
- expect(screen.queryByTestId("lead-capture-form")).toBeNull();
142
- expect(container.querySelector("a[href='#signup']")).not.toBeNull();
218
+ expect(screen.getAllByTestId("newsletter-form")).toHaveLength(1);
219
+ expect(document.querySelector("a[href='#signup']")).not.toBeNull();
143
220
  });
144
221
 
145
- it.each([
146
- ["newsletter:false", () => mockCaptureStatus.mockResolvedValue(status(false))],
147
- ["the status read rejecting", () => mockCaptureStatus.mockRejectedValue(new Error("ECONNREFUSED"))],
148
- ["a 404 (older API)", () => mockCaptureStatus.mockResolvedValue({ ok: false, status: 404, body: {} })],
149
- ])(
150
- "%s + _base's newsletter section → the section is absent, and so is the hero CTA to it",
151
- async (_label, arrange) => {
152
- arrange();
153
-
154
- const { container } = await renderWith(BASE_SECTIONS);
155
-
156
- expect(container.querySelector("[data-section-id='signup']")).toBeNull();
157
- expect(container.querySelector("form")).toBeNull();
158
- expect(container.innerHTML).not.toContain("/v1/leads/subscribe");
159
- expect(container.querySelector("a[href='#signup']")).toBeNull();
160
- expect(screen.queryByTestId("lead-capture-form")).toBeNull();
161
- // The rest of the page still renders.
162
- expect(container.querySelector("[data-section-id='hero']")).not.toBeNull();
163
- expect(container.querySelector("[data-section-id='faq']")).not.toBeNull();
164
- },
165
- );
222
+ it("newsletter:false → the signup section and the hero CTA to it are dropped", async () => {
223
+ mockCaptureStatus.mockResolvedValue(status(false));
224
+
225
+ await renderWith(LEGACY_SECTIONS);
226
+
227
+ expect(screen.queryByTestId("newsletter-form")).toBeNull();
228
+ expect(document.querySelector("a[href='#signup']")).toBeNull();
229
+ });
230
+
231
+ it("newsletter:true + no signup section → the magnet's form is appended", async () => {
232
+ await renderWith([{ id: "faq", type: "faq", data: { items: [] } }]);
233
+
234
+ expect(screen.getAllByTestId("newsletter-form")).toHaveLength(1);
235
+ });
166
236
  });
@@ -3,21 +3,26 @@
3
3
  // content the route 404s. Mirrors for/page.tsx: a sections-composed
4
4
  // page renders its sections, a prose-only page its MDX body.
5
5
  //
6
- // A signup only when there is somewhere to keep it (#6730 D9). The API says
7
- // whether it accepts a subscription (`GET /v1/leads/capture` — a database is
8
- // set up):
6
+ // The newsletter magnet is the only source (#8847 D11): the page reads the
7
+ // workspace's `newsletter` magnet (`flows.get("newsletter")`, shared for 15
8
+ // seconds per instance like /contact's read), else the seeded
9
+ // `newsletterFlowConfig` when the hub cannot be read. Its step title is the
10
+ // page's H1 and `<title>`, its body the line under it (`framedSources`), and
11
+ // the standard `NewsletterFormInline` — label, consent, help line, button and
12
+ // ending all the magnet's — goes into the page's first `contact-form`
13
+ // section, bound to `subscribeNewsletterAction`. Rendered on every request.
9
14
  //
10
- // - accepting: a content `newsletter` section renders as authored (its own
11
- // form posts to `/v1/leads/subscribe`); a page with no such section gets
12
- // the shared `LeadCaptureForm` (one email field) appended, bound to
13
- // `subscribeNewsletterAction`.
14
- // - otherwise — including an unreachable API — every `newsletter` section
15
- // is dropped before render, and a leading hero's CTA that pointed at a
16
- // dropped section's anchor goes with it, so no signup form of any kind
17
- // renders and no button points at a section that is gone. The API
18
- // refuses a subscribe in that state anyway, so a cached page can never
19
- // record a false success.
20
- import { leads } from "@working-theory/api-client";
15
+ // A signup only when there is somewhere to keep it (#6730 D9): when the API
16
+ // does not accept a subscription (`GET /v1/leads/capture` — a database is set
17
+ // up), the slot holds `ContactFallback` with the site's contact email.
18
+ //
19
+ // An authored `newsletter` section never renders beside the slot. A page with
20
+ // no `contact-form` section keeps the composition it had before
21
+ // #8847: a content `newsletter` section renders as authored when accepting
22
+ // (its own form posts to `/v1/leads/subscribe`), a page with no such section
23
+ // gets the signup appended, and when not accepting every `newsletter` section
24
+ // is dropped before render, with any leading-hero CTA that pointed at a
25
+ // dropped section's anchor.
21
26
  import type {
22
27
  getMdxSource as defaultGetMdxSource,
23
28
  getPageSections as defaultGetPageSections,
@@ -28,14 +33,25 @@ import {
28
33
  renderPage,
29
34
  renderPageMetadata,
30
35
  } from "@working-theory/page-renderer";
31
- import { LeadCaptureForm, localeHref } from "@working-theory/ui";
36
+ import { ContactFallback } from "@working-theory/ui";
37
+ import { NEWSLETTER_FLOW_SLUG, newsletterFlowConfig } from "@working-theory/validation";
32
38
  import type { Metadata } from "next";
33
39
  import { getTranslations } from "next-intl/server";
34
40
  import type { ReactElement } from "react";
35
41
 
36
- import { subscribeNewsletterAction } from "./newsletter.actions";
42
+ import {
43
+ framedSources,
44
+ inPageView,
45
+ type InPageView,
46
+ newsletterSignup,
47
+ type NewsletterFormView,
48
+ readCaptureStatus,
49
+ readServedMagnet,
50
+ } from "../../../lib/in-page-magnet";
51
+ import { readSiteContactEmail } from "../contact/site-contact-email";
37
52
 
38
- export const revalidate = 60;
53
+ // #8847 D11 — rendered on every request; the magnet read behind it is shared for 15 seconds.
54
+ export const dynamic = "force-dynamic";
39
55
 
40
56
  interface Props {
41
57
  params: Promise<{ locale: string }>;
@@ -44,15 +60,16 @@ interface Props {
44
60
  }
45
61
 
46
62
  type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
63
+ type NewsletterView = InPageView & { form: NewsletterFormView };
47
64
 
48
- /** Whether the API accepts a subscription; `false` for any failed read. */
49
- async function acceptsSubscription(): Promise<boolean> {
50
- try {
51
- const res = await leads.captureStatus();
52
- return res.ok && res.body.ok === true && res.body.data.newsletter;
53
- } catch {
54
- return false;
55
- }
65
+ /** The served newsletter magnet's view when it maps, else the seeded config's. */
66
+ async function newsletterView(locale: string): Promise<NewsletterView> {
67
+ const magnet = await readServedMagnet(NEWSLETTER_FLOW_SLUG);
68
+ const view =
69
+ (magnet ? inPageView(magnet, locale, "newsletter") : null) ??
70
+ // The seeded config always maps: one step, its email and consent fields.
71
+ inPageView(newsletterFlowConfig, locale, "newsletter")!;
72
+ return view as NewsletterView;
56
73
  }
57
74
 
58
75
  /**
@@ -77,12 +94,22 @@ function withoutSignup(sections: Section[]): Section[] {
77
94
 
78
95
  export async function generateMetadata({
79
96
  params,
80
- }: Pick<Props, "params">): Promise<Metadata> {
97
+ getMdxSource,
98
+ getPageSections,
99
+ }: Props): Promise<Metadata> {
81
100
  const { locale } = await params;
101
+ const view = await newsletterView(locale);
102
+ // #8847 D11 — the `<title>` is the magnet's step title, like the H1.
103
+ const framed = framedSources({
104
+ getMdxSource: getMdxSource ?? loadMdxSource,
105
+ getPageSections: getPageSections ?? loadPageSections,
106
+ view,
107
+ });
82
108
  return renderPageMetadata({
83
109
  type: "NewsletterSignup",
84
110
  slug: "newsletter",
85
111
  locale,
112
+ getMdxSource: framed.getMdxSource,
86
113
  });
87
114
  }
88
115
 
@@ -92,51 +119,61 @@ export default async function Page({
92
119
  getPageSections,
93
120
  }: Props): Promise<ReactElement> {
94
121
  const { locale } = await params;
95
- const load = getMdxSource ?? loadMdxSource;
96
- const loadSections = getPageSections ?? loadPageSections;
97
- const [source, accepting] = await Promise.all([
98
- load("NewsletterSignup", "newsletter", locale),
99
- acceptsSubscription(),
100
- ]);
101
- const declared = source ? await loadSections(source.frontmatter) : [];
122
+ const [status, view] = await Promise.all([readCaptureStatus(), newsletterView(locale)]);
123
+ const accepting = status?.newsletter === true;
124
+ const framed = framedSources({
125
+ getMdxSource: getMdxSource ?? loadMdxSource,
126
+ getPageSections: getPageSections ?? loadPageSections,
127
+ view,
128
+ });
129
+ const source = await framed.getMdxSource("NewsletterSignup", "newsletter", locale);
130
+ const declared = source ? await framed.getPageSections(source.frontmatter) : [];
102
131
  const composed = declared.length > 0;
132
+ const slot = declared.find((s) => s.type === "contact-form")?.id;
103
133
  const hasSignupSection = declared.some((s) => s.type === "newsletter");
134
+ const contactEmail = readSiteContactEmail();
135
+
136
+ let surface: ReactElement;
137
+ if (accepting) {
138
+ surface = newsletterSignup(view, {
139
+ locale,
140
+ className: "mx-auto",
141
+ ...(contactEmail !== undefined && { fallbackEmail: contactEmail }),
142
+ });
143
+ } else {
144
+ const tc = await getTranslations({ locale, namespace: "contact" });
145
+ surface = (
146
+ <ContactFallback
147
+ email={contactEmail}
148
+ text={{
149
+ heading: tc("unavailableHeading"),
150
+ body: tc("unavailableBody", { email: "{email}" }),
151
+ noEmail: tc("unavailableNoEmail"),
152
+ }}
153
+ />
154
+ );
155
+ }
104
156
 
105
157
  const rendered = await renderPage({
106
158
  type: "NewsletterSignup",
107
159
  slug: "newsletter",
108
160
  locale,
109
161
  ...(composed ? {} : { bodyMode: "mdx-body" as const }),
110
- ...(getMdxSource !== undefined && { getMdxSource }),
111
- getPageSections: accepting
112
- ? loadSections
113
- : async (frontmatter: unknown) => withoutSignup(await loadSections(frontmatter)),
162
+ getMdxSource: framed.getMdxSource,
163
+ // With a slot the magnet's form is the page's one signup, so an authored
164
+ // `newsletter` section (posting around the magnet) never renders beside it.
165
+ getPageSections:
166
+ accepting && slot === undefined
167
+ ? framed.getPageSections
168
+ : async (frontmatter: unknown) => withoutSignup(await framed.getPageSections(frontmatter)),
169
+ ...(slot !== undefined && { sectionSlots: { [slot]: surface } }),
114
170
  });
115
171
 
116
- if (!accepting || hasSignupSection || source === null) return rendered;
117
-
118
- const [t, tc] = await Promise.all([
119
- getTranslations({ locale, namespace: "newsletter" }),
120
- getTranslations({ locale, namespace: "contact" }),
121
- ]);
172
+ if (slot !== undefined || !accepting || hasSignupSection || source === null) return rendered;
122
173
  return (
123
174
  <>
124
175
  {rendered}
125
- <LeadCaptureForm
126
- action={subscribeNewsletterAction}
127
- fields={[{ name: "email", label: t("emailLabel"), type: "email", required: true }]}
128
- termsHref={localeHref(locale, "/terms")}
129
- text={{
130
- submit: t("submit"),
131
- sending: t("sending"),
132
- success: t("success"),
133
- consentStatement: t("consentStatement"),
134
- genericError: tc("genericError"),
135
- unavailableHeading: tc("unavailableHeading"),
136
- unavailableBody: tc("unavailableBody", { email: "{email}" }),
137
- unavailableNoEmail: tc("unavailableNoEmail"),
138
- }}
139
- />
176
+ {surface}
140
177
  </>
141
178
  );
142
179
  }
@@ -0,0 +1,7 @@
1
+ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2
+
3
+ exports[`/pricing on an unconnected site is today's page (#8927 AC-4) renders today's DI page for ?plan=BASIC 1`] = `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Pricing","description":"Plans for every stage."},{"@context":"https://schema.org","@type":"Offer","name":"Basic","price":"29","priceCurrency":"USD"},{"@context":"https://schema.org","@type":"Offer","name":"Business","price":"79","priceCurrency":"USD"},{"@context":"https://schema.org","@type":"Offer","name":"Agency","price":"199","priceCurrency":"USD"},{"@context":"https://schema.org","@type":"AggregateOffer","priceCurrency":"USD","lowPrice":"29","highPrice":"199","offerCount":3}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">Pricing</h1><p data-testid="pricing-description" class="mt-block-tight text-lead">Plans for every stage.</p></div><div class="ailk-section-renderer" data-testid="section-renderer"><span id="section-plans" class="ailk-section-anchor" aria-hidden="true" data-testid="section-anchor"></span><section class="py-section bg-(--color-bg)" data-testid="pricing" data-section="pricing"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8"><span hidden="" data-testid="pricing-recommended-anchor"></span><div class="grid gap-grid md:grid-cols-2 lg:grid-cols-3 mt-10 items-stretch pricing-card-row-grid" data-testid="pricing-grid" data-separation="gapped"><div class="ailk-pricing-tier-card pricing-card-subgrid" data-testid="pricing-tier-card" aria-current="true"><div class="pricing-card-subgrid-body"><div class="h-full rounded-card p-card pricing-card-subgrid border-thin border-(--color-brand) bg-(--color-surface-raised)" data-testid="card"><div class="row-start-1 flex flex-wrap items-center gap-2 justify-between"><h3 class="text-heading-3 font-display text-fg text-2xl font-normal">Basic</h3><div class="group inline-flex items-center border-hairline rounded-full transition-colors focus-visible:outline-none bg-(--color-brand) border-transparent text-(--color-fg-on-brand) hover:bg-(--color-brand)/90 focus-visible:bg-(--color-brand)/90 ps-2 pe-2 py-0.5 text-body-sm" data-testid="tier-badge">Recommended for you</div></div><div class="row-start-3 mt-6"><h4 class="text-heading-4 font-display text-fg text-4xl font-medium tracking-tight">$29</h4><span class="text-body font-body leading-normal text-fg mt-1 text-body-sm text-(--color-text-muted)">/month</span></div><div class="row-start-5 mt-6"><a class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 bg-(--color-brand) text-(--color-fg-on-brand) not-disabled:hover:bg-(--button-hover-bg) not-disabled:hover:text-(--button-hover-fg) not-disabled:hover:outline not-disabled:hover:outline-1 not-disabled:hover:-outline-offset-1 not-disabled:hover:outline-(--button-hover-border) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:opacity-80 rounded-control px-control-x min-h-(--control-height-big) w-full" href="/checkout/basic" data-testid="tier-cta">Choose Basic</a></div><ul class="row-start-6 mt-block-loose space-y-3" role="list"><li class="flex items-start gap-2"><span data-testid="feature-check-circle" class="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full check-badge"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check size-[var(--icon-size-xs)] check-glyph" aria-label="Included" role="img"><path d="M20 6 9 17l-5-5"></path></svg></span><span class="text-body font-body leading-normal text-fg text-body-sm text-(--color-fg)">One site</span></li></ul></div></div></div><div class="ailk-pricing-tier-card pricing-card-subgrid" data-testid="pricing-tier-card"><div class="pricing-card-subgrid-body"><div class="h-full rounded-card p-card pricing-card-subgrid border-hairline border-(--color-border) bg-(--color-surface-raised)" data-testid="card"><div class="row-start-1 flex flex-wrap items-center gap-2 justify-between"><h3 class="text-heading-3 font-display text-fg text-2xl font-normal">Business</h3></div><div class="row-start-3 mt-6"><h4 class="text-heading-4 font-display text-fg text-4xl font-medium tracking-tight">$79</h4><span class="text-body font-body leading-normal text-fg mt-1 text-body-sm text-(--color-text-muted)">/month</span></div><div class="row-start-5 mt-6"><a class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 border-hairline border-(--color-border) text-(--color-fg) bg-transparent not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x min-h-(--control-height-big) w-full" href="/checkout/business" data-testid="tier-cta">Choose Business</a></div><ul class="row-start-6 mt-block-loose space-y-3" role="list"><li class="flex items-start gap-2"><span data-testid="feature-check-circle" class="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full check-badge"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check size-[var(--icon-size-xs)] check-glyph" aria-label="Included" role="img"><path d="M20 6 9 17l-5-5"></path></svg></span><span class="text-body font-body leading-normal text-fg text-body-sm text-(--color-fg)">Three sites</span></li></ul></div></div></div><div class="ailk-pricing-tier-card pricing-card-subgrid" data-testid="pricing-tier-card"><div class="pricing-card-subgrid-body"><div class="h-full rounded-card p-card pricing-card-subgrid border-hairline border-(--color-border) bg-(--color-surface-raised)" data-testid="card"><div class="row-start-1 flex flex-wrap items-center gap-2 justify-between"><h3 class="text-heading-3 font-display text-fg text-2xl font-normal">Agency</h3></div><div class="row-start-3 mt-6"><h4 class="text-heading-4 font-display text-fg text-4xl font-medium tracking-tight">$199</h4><span class="text-body font-body leading-normal text-fg mt-1 text-body-sm text-(--color-text-muted)">/month</span></div><div class="row-start-5 mt-6"><a class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 border-hairline border-(--color-border) text-(--color-fg) bg-transparent not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x min-h-(--control-height-big) w-full" href="/checkout/agency" data-testid="tier-cta">Choose Agency</a></div><ul class="row-start-6 mt-block-loose space-y-3" role="list"><li class="flex items-start gap-2"><span data-testid="feature-check-circle" class="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full check-badge"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check size-[var(--icon-size-xs)] check-glyph" aria-label="Included" role="img"><path d="M20 6 9 17l-5-5"></path></svg></span><span class="text-body font-body leading-normal text-fg text-body-sm text-(--color-fg)">Unlimited sites</span></li></ul></div></div></div></div></div></section></div></main>"`;
4
+
5
+ exports[`/pricing on an unconnected site is today's page (#8927 AC-4) renders today's DI page for ?plan=agency 1`] = `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Pricing","description":"Plans for every stage."},{"@context":"https://schema.org","@type":"Offer","name":"Basic","price":"29","priceCurrency":"USD"},{"@context":"https://schema.org","@type":"Offer","name":"Business","price":"79","priceCurrency":"USD"},{"@context":"https://schema.org","@type":"Offer","name":"Agency","price":"199","priceCurrency":"USD"},{"@context":"https://schema.org","@type":"AggregateOffer","priceCurrency":"USD","lowPrice":"29","highPrice":"199","offerCount":3}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">Pricing</h1><p data-testid="pricing-description" class="mt-block-tight text-lead">Plans for every stage.</p></div><div class="ailk-section-renderer" data-testid="section-renderer"><span id="section-plans" class="ailk-section-anchor" aria-hidden="true" data-testid="section-anchor"></span><section class="py-section bg-(--color-bg)" data-testid="pricing" data-section="pricing"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8"><span hidden="" data-testid="pricing-recommended-anchor"></span><div class="grid gap-grid md:grid-cols-2 lg:grid-cols-3 mt-10 items-stretch pricing-card-row-grid" data-testid="pricing-grid" data-separation="gapped"><div class="ailk-pricing-tier-card pricing-card-subgrid" data-testid="pricing-tier-card"><div class="pricing-card-subgrid-body"><div class="h-full rounded-card p-card pricing-card-subgrid border-hairline border-(--color-border) bg-(--color-surface-raised)" data-testid="card"><div class="row-start-1 flex flex-wrap items-center gap-2 justify-between"><h3 class="text-heading-3 font-display text-fg text-2xl font-normal">Basic</h3></div><div class="row-start-3 mt-6"><h4 class="text-heading-4 font-display text-fg text-4xl font-medium tracking-tight">$29</h4><span class="text-body font-body leading-normal text-fg mt-1 text-body-sm text-(--color-text-muted)">/month</span></div><div class="row-start-5 mt-6"><a class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 border-hairline border-(--color-border) text-(--color-fg) bg-transparent not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x min-h-(--control-height-big) w-full" href="/checkout/basic" data-testid="tier-cta">Choose Basic</a></div><ul class="row-start-6 mt-block-loose space-y-3" role="list"><li class="flex items-start gap-2"><span data-testid="feature-check-circle" class="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full check-badge"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check size-[var(--icon-size-xs)] check-glyph" aria-label="Included" role="img"><path d="M20 6 9 17l-5-5"></path></svg></span><span class="text-body font-body leading-normal text-fg text-body-sm text-(--color-fg)">One site</span></li></ul></div></div></div><div class="ailk-pricing-tier-card pricing-card-subgrid" data-testid="pricing-tier-card"><div class="pricing-card-subgrid-body"><div class="h-full rounded-card p-card pricing-card-subgrid border-hairline border-(--color-border) bg-(--color-surface-raised)" data-testid="card"><div class="row-start-1 flex flex-wrap items-center gap-2 justify-between"><h3 class="text-heading-3 font-display text-fg text-2xl font-normal">Business</h3></div><div class="row-start-3 mt-6"><h4 class="text-heading-4 font-display text-fg text-4xl font-medium tracking-tight">$79</h4><span class="text-body font-body leading-normal text-fg mt-1 text-body-sm text-(--color-text-muted)">/month</span></div><div class="row-start-5 mt-6"><a class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 border-hairline border-(--color-border) text-(--color-fg) bg-transparent not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x min-h-(--control-height-big) w-full" href="/checkout/business" data-testid="tier-cta">Choose Business</a></div><ul class="row-start-6 mt-block-loose space-y-3" role="list"><li class="flex items-start gap-2"><span data-testid="feature-check-circle" class="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full check-badge"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check size-[var(--icon-size-xs)] check-glyph" aria-label="Included" role="img"><path d="M20 6 9 17l-5-5"></path></svg></span><span class="text-body font-body leading-normal text-fg text-body-sm text-(--color-fg)">Three sites</span></li></ul></div></div></div><div class="ailk-pricing-tier-card pricing-card-subgrid" data-testid="pricing-tier-card" aria-current="true"><div class="pricing-card-subgrid-body"><div class="h-full rounded-card p-card pricing-card-subgrid border-thin border-(--color-brand) bg-(--color-surface-raised)" data-testid="card"><div class="row-start-1 flex flex-wrap items-center gap-2 justify-between"><h3 class="text-heading-3 font-display text-fg text-2xl font-normal">Agency</h3><div class="group inline-flex items-center border-hairline rounded-full transition-colors focus-visible:outline-none bg-(--color-brand) border-transparent text-(--color-fg-on-brand) hover:bg-(--color-brand)/90 focus-visible:bg-(--color-brand)/90 ps-2 pe-2 py-0.5 text-body-sm" data-testid="tier-badge">Recommended for you</div></div><div class="row-start-3 mt-6"><h4 class="text-heading-4 font-display text-fg text-4xl font-medium tracking-tight">$199</h4><span class="text-body font-body leading-normal text-fg mt-1 text-body-sm text-(--color-text-muted)">/month</span></div><div class="row-start-5 mt-6"><a class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 bg-(--color-brand) text-(--color-fg-on-brand) not-disabled:hover:bg-(--button-hover-bg) not-disabled:hover:text-(--button-hover-fg) not-disabled:hover:outline not-disabled:hover:outline-1 not-disabled:hover:-outline-offset-1 not-disabled:hover:outline-(--button-hover-border) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:opacity-80 rounded-control px-control-x min-h-(--control-height-big) w-full" href="/checkout/agency" data-testid="tier-cta">Choose Agency</a></div><ul class="row-start-6 mt-block-loose space-y-3" role="list"><li class="flex items-start gap-2"><span data-testid="feature-check-circle" class="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full check-badge"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check size-[var(--icon-size-xs)] check-glyph" aria-label="Included" role="img"><path d="M20 6 9 17l-5-5"></path></svg></span><span class="text-body font-body leading-normal text-fg text-body-sm text-(--color-fg)">Unlimited sites</span></li></ul></div></div></div></div></div></section></div></main>"`;
6
+
7
+ exports[`/pricing on an unconnected site is today's page (#8927 AC-4) renders today's DI page for no plan 1`] = `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Pricing","description":"Plans for every stage."},{"@context":"https://schema.org","@type":"Offer","name":"Basic","price":"29","priceCurrency":"USD"},{"@context":"https://schema.org","@type":"Offer","name":"Business","price":"79","priceCurrency":"USD"},{"@context":"https://schema.org","@type":"Offer","name":"Agency","price":"199","priceCurrency":"USD"},{"@context":"https://schema.org","@type":"AggregateOffer","priceCurrency":"USD","lowPrice":"29","highPrice":"199","offerCount":3}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">Pricing</h1><p data-testid="pricing-description" class="mt-block-tight text-lead">Plans for every stage.</p></div><div class="ailk-section-renderer" data-testid="section-renderer"><span id="section-plans" class="ailk-section-anchor" aria-hidden="true" data-testid="section-anchor"></span><section class="py-section bg-(--color-bg)" data-testid="pricing" data-section="pricing"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8"><div class="grid gap-grid md:grid-cols-2 lg:grid-cols-3 mt-10 items-stretch pricing-card-row-grid" data-testid="pricing-grid" data-separation="gapped"><div class="ailk-pricing-tier-card pricing-card-subgrid" data-testid="pricing-tier-card"><div class="pricing-card-subgrid-body"><div class="h-full rounded-card p-card pricing-card-subgrid border-hairline border-(--color-border) bg-(--color-surface-raised)" data-testid="card"><div class="row-start-1 flex flex-wrap items-center gap-2 justify-between"><h3 class="text-heading-3 font-display text-fg text-2xl font-normal">Basic</h3></div><div class="row-start-3 mt-6"><h4 class="text-heading-4 font-display text-fg text-4xl font-medium tracking-tight">$29</h4><span class="text-body font-body leading-normal text-fg mt-1 text-body-sm text-(--color-text-muted)">/month</span></div><div class="row-start-5 mt-6"><a class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 border-hairline border-(--color-border) text-(--color-fg) bg-transparent not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x min-h-(--control-height-big) w-full" href="/checkout/basic" data-testid="tier-cta">Choose Basic</a></div><ul class="row-start-6 mt-block-loose space-y-3" role="list"><li class="flex items-start gap-2"><span data-testid="feature-check-circle" class="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full check-badge"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check size-[var(--icon-size-xs)] check-glyph" aria-label="Included" role="img"><path d="M20 6 9 17l-5-5"></path></svg></span><span class="text-body font-body leading-normal text-fg text-body-sm text-(--color-fg)">One site</span></li></ul></div></div></div><div class="ailk-pricing-tier-card pricing-card-subgrid" data-testid="pricing-tier-card" aria-current="true"><div class="pricing-card-subgrid-body"><div class="h-full rounded-card p-card pricing-card-subgrid border-thin border-(--color-brand) bg-(--color-surface-raised)" data-testid="card"><div class="row-start-1 flex flex-wrap items-center gap-2 justify-between"><h3 class="text-heading-3 font-display text-fg text-2xl font-normal">Business</h3><div class="group inline-flex items-center border-hairline rounded-full transition-colors focus-visible:outline-none bg-(--color-brand) border-transparent text-(--color-fg-on-brand) hover:bg-(--color-brand)/90 focus-visible:bg-(--color-brand)/90 ps-2 pe-2 py-0.5 text-body-sm" data-testid="tier-badge">Recommended</div></div><div class="row-start-3 mt-6"><h4 class="text-heading-4 font-display text-fg text-4xl font-medium tracking-tight">$79</h4><span class="text-body font-body leading-normal text-fg mt-1 text-body-sm text-(--color-text-muted)">/month</span></div><div class="row-start-5 mt-6"><a class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 bg-(--color-brand) text-(--color-fg-on-brand) not-disabled:hover:bg-(--button-hover-bg) not-disabled:hover:text-(--button-hover-fg) not-disabled:hover:outline not-disabled:hover:outline-1 not-disabled:hover:-outline-offset-1 not-disabled:hover:outline-(--button-hover-border) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:opacity-80 rounded-control px-control-x min-h-(--control-height-big) w-full" href="/checkout/business" data-testid="tier-cta">Choose Business</a></div><ul class="row-start-6 mt-block-loose space-y-3" role="list"><li class="flex items-start gap-2"><span data-testid="feature-check-circle" class="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full check-badge"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check size-[var(--icon-size-xs)] check-glyph" aria-label="Included" role="img"><path d="M20 6 9 17l-5-5"></path></svg></span><span class="text-body font-body leading-normal text-fg text-body-sm text-(--color-fg)">Three sites</span></li></ul></div></div></div><div class="ailk-pricing-tier-card pricing-card-subgrid" data-testid="pricing-tier-card"><div class="pricing-card-subgrid-body"><div class="h-full rounded-card p-card pricing-card-subgrid border-hairline border-(--color-border) bg-(--color-surface-raised)" data-testid="card"><div class="row-start-1 flex flex-wrap items-center gap-2 justify-between"><h3 class="text-heading-3 font-display text-fg text-2xl font-normal">Agency</h3></div><div class="row-start-3 mt-6"><h4 class="text-heading-4 font-display text-fg text-4xl font-medium tracking-tight">$199</h4><span class="text-body font-body leading-normal text-fg mt-1 text-body-sm text-(--color-text-muted)">/month</span></div><div class="row-start-5 mt-6"><a class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 border-hairline border-(--color-border) text-(--color-fg) bg-transparent not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x min-h-(--control-height-big) w-full" href="/checkout/agency" data-testid="tier-cta">Choose Agency</a></div><ul class="row-start-6 mt-block-loose space-y-3" role="list"><li class="flex items-start gap-2"><span data-testid="feature-check-circle" class="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full check-badge"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check size-[var(--icon-size-xs)] check-glyph" aria-label="Included" role="img"><path d="M20 6 9 17l-5-5"></path></svg></span><span class="text-body font-body leading-normal text-fg text-body-sm text-(--color-fg)">Unlimited sites</span></li></ul></div></div></div></div></div></section></div></main>"`;