create-ailk 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/component-catalog.json +149 -5
  2. package/component-catalog.md +7 -6
  3. package/dist/add-module.js +11 -9
  4. package/dist/add-route.d.ts +55 -1
  5. package/dist/add-route.js +294 -12
  6. package/dist/cli.js +27 -10
  7. package/dist/derive-architecture.d.ts +7 -5
  8. package/dist/derive-architecture.js +9 -32
  9. package/dist/lib/design-checks.js +4 -2
  10. package/dist/lib/env-catalog.js +0 -4
  11. package/dist/lib/npm-auth.d.ts +9 -0
  12. package/dist/lib/npm-auth.js +48 -0
  13. package/dist/lib/scaffold-render-sweep.d.ts +3 -2
  14. package/dist/lib/scaffold-render-sweep.js +5 -8
  15. package/dist/module-architecture.js +98 -10
  16. package/dist/paid-sections.d.ts +21 -2
  17. package/dist/paid-sections.js +69 -7
  18. package/dist/seed-flows.d.ts +4 -1
  19. package/dist/seed-flows.js +50 -4
  20. package/dist/spoke-checkout.d.ts +38 -35
  21. package/dist/spoke-checkout.js +66 -229
  22. package/dist/starter-grid.d.ts +0 -3
  23. package/dist/starter-grid.js +24 -14
  24. package/dist/surfaces.js +33 -12
  25. package/dist/sync-routes.d.ts +38 -1
  26. package/dist/sync-routes.js +184 -11
  27. package/dist/template-record.d.ts +1 -1
  28. package/dist/template-record.js +3 -2
  29. package/dist/update.js +10 -0
  30. package/package.json +3 -3
  31. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  32. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  35. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  37. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  38. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  40. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  41. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  43. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  44. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  46. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  47. package/templates/.env.example +9 -7
  48. package/templates/.github/workflows/ci.yml +13 -0
  49. package/templates/README.md +1 -0
  50. package/templates/_gitignore +4 -0
  51. package/templates/apps/api/.env.example +27 -0
  52. package/templates/apps/api/CLAUDE.md +77 -16
  53. package/templates/apps/api/package.json +1 -0
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  55. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  56. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  57. package/templates/apps/api/src/config/index.ts +109 -0
  58. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  59. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  60. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  61. package/templates/apps/api/src/lib/stripe.ts +74 -4
  62. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  63. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  64. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  65. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  66. package/templates/apps/api/src/openapi/spec.ts +10 -7
  67. package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
  68. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  69. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
  70. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  71. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  72. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  73. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  74. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  75. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  76. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  77. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  78. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  79. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  80. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  81. package/templates/apps/api/src/routes/content/index.ts +16 -2
  82. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  83. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  84. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  85. package/templates/apps/api/src/routes/leads/index.ts +17 -14
  86. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  87. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  88. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  89. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
  90. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  91. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  92. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  93. package/templates/apps/api/src/routes/webhooks/stripe.ts +91 -17
  94. package/templates/apps/api/src/server.ts +140 -51
  95. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  96. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
  97. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  98. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  99. package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
  100. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
  101. package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
  102. package/templates/apps/api/src/services/entitlements.ts +157 -1
  103. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  104. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
  105. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  106. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
  107. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  108. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
  109. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
  110. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  111. package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
  112. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
  113. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  114. package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
  115. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  116. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  117. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  118. package/templates/apps/api/src/services/person.ts +124 -9
  119. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  120. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  121. package/templates/apps/mcp/CLAUDE.md +22 -14
  122. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  123. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  124. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  125. package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
  126. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  127. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  128. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  129. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  130. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  131. package/templates/apps/mcp/src/server.ts +5 -0
  132. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  133. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  134. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  135. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  136. package/templates/apps/web/.env.example +10 -31
  137. package/templates/apps/web/CLAUDE.md +315 -42
  138. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  139. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  140. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  141. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  142. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  143. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  144. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  145. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  146. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  147. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  148. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  149. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  150. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  151. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  152. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  153. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  154. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +6 -2
  155. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  156. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  157. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  158. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  159. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  160. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  161. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  162. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
  163. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  164. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  165. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  166. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  167. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  168. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  169. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  170. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  171. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  172. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  173. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  174. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  175. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  176. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  177. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  178. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  179. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  180. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  181. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  182. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  183. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
  184. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  185. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  187. package/templates/apps/web/lib/auth-routes.ts +36 -2
  188. package/templates/apps/web/lib/checkout-config.ts +9 -20
  189. package/templates/apps/web/lib/deck-page.ts +1 -0
  190. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  191. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  192. package/templates/apps/web/lib/magnet-routes.ts +42 -0
  193. package/templates/apps/web/lib/modules.generated.ts +9 -0
  194. package/templates/apps/web/lib/newsletter.actions.ts +202 -0
  195. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  196. package/templates/apps/web/lib/posting-title.ts +17 -0
  197. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  198. package/templates/apps/web/messages/en.json +49 -15
  199. package/templates/apps/web/middleware.ts +12 -2
  200. package/templates/apps/web/security-headers.config.ts +17 -0
  201. package/templates/content/en/BlogList/blog.mdx +4 -71
  202. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  203. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  204. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  205. package/templates/content/en/BookCall/start-project.mdx +1 -17
  206. package/templates/content/en/ContactPage/contact.mdx +6 -63
  207. package/templates/content/en/HomePage/home.mdx +3 -21
  208. package/templates/database/CHANGELOG.md +31 -0
  209. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  210. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  211. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  212. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  213. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  214. package/templates/database/__tests__/seed.test.ts +31 -1
  215. package/templates/database/auth/schema.prisma +4 -0
  216. package/templates/database/config/schema.prisma +91 -0
  217. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  218. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  219. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  220. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  221. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  222. package/templates/database/package.json +1 -1
  223. package/templates/database/seed.ts +30 -2
  224. package/versions.json +35 -35
  225. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  226. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  227. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  228. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  229. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  230. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  231. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  232. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  233. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  234. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  235. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  236. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  237. package/templates/apps/web/lib/checkout.ts +0 -93
  238. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -0,0 +1,599 @@
1
+ /**
2
+ * /pricing renders the workspace's Offerings shown set (#8927, spec
3
+ * docs/specs/8927-pricing-renders-the-shown-set-spec.md AC-1–AC-3, AC-5).
4
+ *
5
+ * A connected fixture workspace (C1): `global.fetch` answers
6
+ * `GET <base>/v1/content/shown-offerings` with the route's own envelope and a
7
+ * `shownSet` in the wire shape the route sends (no workspace or organization
8
+ * id). The real api-client, the real mapper and the real Pricing section run.
9
+ * The site's contact email is read from a temporary content root's
10
+ * `_site.mdx`. The fixture is parsed with the shown-set schema first, so it
11
+ * cannot drift from spec 8876 D4.
12
+ *
13
+ * The page shares one read for 15 seconds, so the suite moves `Date.now` 16
14
+ * seconds before every case; AC-2 moves it by hand inside its cases.
15
+ */
16
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join, resolve } from "node:path";
19
+
20
+ import { act, fireEvent, render, screen, within } from "@testing-library/react";
21
+ import {
22
+ PageSectionsSchema,
23
+ workspaceShownSetSchema,
24
+ type OfferingsPrice,
25
+ type OfferingsProduct,
26
+ } from "@working-theory/validation";
27
+
28
+ import PricingPage, { generateMetadata } from "../page";
29
+
30
+ const SITE_EMAIL = "hello@example.com";
31
+ const HOSTILE = "</script><script>alert(1)</script>";
32
+
33
+ // ── The connected fixture ────────────────────────────────────────────────────
34
+
35
+ const INTERVAL = { monthly: "month", yearly: "year", once: null } as const;
36
+
37
+ function price(
38
+ plan: string,
39
+ period: OfferingsPrice["period"],
40
+ amount: number,
41
+ ): OfferingsPrice {
42
+ return {
43
+ priceId: `price_${plan.replace(/-/g, "")}${period}`,
44
+ lookupKey: `${plan}_${period}`,
45
+ plan,
46
+ period,
47
+ interval: INTERVAL[period],
48
+ amount,
49
+ currency: "usd",
50
+ };
51
+ }
52
+
53
+ function product(
54
+ plan: string,
55
+ ailkGroup: OfferingsProduct["ailkGroup"],
56
+ name: string,
57
+ prices: OfferingsPrice[],
58
+ extra: Partial<OfferingsProduct> = {},
59
+ ): OfferingsProduct {
60
+ return {
61
+ productId: `prod_${plan.replace(/-/g, "")}`,
62
+ plan,
63
+ name,
64
+ description: null,
65
+ marketingFeatures: [],
66
+ unitLabel: null,
67
+ note: null,
68
+ termsNote: null,
69
+ ailkGroup,
70
+ ailkOrder: null,
71
+ prices,
72
+ recommended: false,
73
+ ...extra,
74
+ };
75
+ }
76
+
77
+ const STARTER = {
78
+ name: "Starter",
79
+ description: "For one small site",
80
+ features: ["One site", "Email support", "Weekly backups"],
81
+ unitLabel: "per site",
82
+ note: "Billed per live site",
83
+ termsNote: "Cancel any time",
84
+ monthly: "$19/mo",
85
+ annual: "$190/yr",
86
+ };
87
+
88
+ function offerings(): OfferingsProduct[] {
89
+ return [
90
+ product(
91
+ "starter",
92
+ "plan",
93
+ STARTER.name,
94
+ [price("starter", "monthly", 1900), price("starter", "yearly", 19000)],
95
+ {
96
+ description: STARTER.description,
97
+ marketingFeatures: STARTER.features,
98
+ unitLabel: STARTER.unitLabel,
99
+ note: STARTER.note,
100
+ termsNote: STARTER.termsNote,
101
+ },
102
+ ),
103
+ product("growth", "plan", "Growth", [price("growth", "monthly", 4900)], {
104
+ recommended: true,
105
+ }),
106
+ product("scale", "plan", "Scale", [price("scale", "once", 150000)]),
107
+ product(
108
+ "seo-audit",
109
+ "addon",
110
+ "SEO audit",
111
+ [price("seo-audit", "monthly", 1500)],
112
+ { unitLabel: "per audit" },
113
+ ),
114
+ ];
115
+ }
116
+
117
+ /** The shown set as the route sends it: parsed, then without the two ids. */
118
+ function wireSet(
119
+ edit: (list: OfferingsProduct[]) => OfferingsProduct[] = (l) => l,
120
+ ) {
121
+ const list = edit(offerings());
122
+ const {
123
+ workspaceId: _w,
124
+ organizationId: _o,
125
+ ...wire
126
+ } = workspaceShownSetSchema.parse({
127
+ workspaceId: "wksp_01890a5d-ac96-774b-bcce-b302099a8057",
128
+ organizationId: "org_01890a5d-ac96-774b-bcce-b302099a8058",
129
+ productIds: list.map((o) => o.productId),
130
+ recommended: {
131
+ plan:
132
+ list.find((o) => o.ailkGroup === "plan" && o.recommended)?.productId ??
133
+ null,
134
+ addon: null,
135
+ },
136
+ offerings: list,
137
+ state: "current",
138
+ snapshotAt: "2026-10-18T00:00:00.000Z",
139
+ });
140
+ return wire;
141
+ }
142
+
143
+ // ── The fetch stub ───────────────────────────────────────────────────────────
144
+
145
+ type Answer =
146
+ | { status: number; body: unknown }
147
+ | { status: number; notJson: true }
148
+ | { reject: true };
149
+
150
+ function shown(set: unknown = wireSet()): Answer {
151
+ return {
152
+ status: 200,
153
+ body: { ok: true, data: { status: "shown", shownSet: set } },
154
+ };
155
+ }
156
+
157
+ const answers: Answer[] = [];
158
+ let answer: Answer = shown();
159
+
160
+ const fetchMock: jest.Mock = jest.fn(async () => {
161
+ const next = answers.shift() ?? answer;
162
+ if ("reject" in next) throw new TypeError("fetch failed");
163
+ return {
164
+ ok: next.status >= 200 && next.status < 300,
165
+ status: next.status,
166
+ json: async () => {
167
+ if ("notJson" in next) throw new SyntaxError("Unexpected token <");
168
+ return next.body;
169
+ },
170
+ };
171
+ });
172
+
173
+ // ── The authored page ────────────────────────────────────────────────────────
174
+
175
+ const getMdxSource = jest.fn().mockResolvedValue({
176
+ frontmatter: {
177
+ type: "Pricing",
178
+ slug: "pricing",
179
+ title: "Pricing",
180
+ description: "Plans for every stage.",
181
+ },
182
+ body: "",
183
+ });
184
+
185
+ const HERO = {
186
+ id: "hero",
187
+ type: "hero",
188
+ data: { headline: "Plans that grow with you", variant: "default" },
189
+ };
190
+ const FAQ = {
191
+ id: "faq",
192
+ type: "faq",
193
+ data: {
194
+ headline: "Questions",
195
+ items: [{ question: "Can I cancel?", answer: "Yes, any time." }],
196
+ },
197
+ };
198
+ const AUTHORED_PLANS = {
199
+ id: "plans",
200
+ type: "pricing",
201
+ data: {
202
+ eyebrow: "Plans",
203
+ headline: "Pick a plan",
204
+ variant: "three-tiers-with-toggle",
205
+ billingToggle: false,
206
+ plans: [
207
+ {
208
+ key: "basic",
209
+ name: "Authored Basic",
210
+ price: { monthly: "$29" },
211
+ features: ["Authored feature"],
212
+ cta: { label: "Choose Basic", href: "/checkout/basic" },
213
+ },
214
+ {
215
+ key: "business",
216
+ name: "Authored Business",
217
+ price: { monthly: "$29.50" },
218
+ features: ["Another authored feature"],
219
+ cta: { label: "Choose Business", href: "/checkout/business" },
220
+ highlighted: true,
221
+ },
222
+ ],
223
+ },
224
+ };
225
+ const AUTHORED_ADDONS = {
226
+ id: "addons",
227
+ type: "pricing",
228
+ data: {
229
+ variant: "addons",
230
+ plans: [],
231
+ addons: [{ name: "Authored add-on", price: "$29", features: [] }],
232
+ },
233
+ };
234
+
235
+ const authoredPage = PageSectionsSchema.parse([
236
+ HERO,
237
+ AUTHORED_PLANS,
238
+ AUTHORED_ADDONS,
239
+ FAQ,
240
+ ]);
241
+ const pageWithoutPricing = PageSectionsSchema.parse([HERO, FAQ]);
242
+
243
+ const getPageSections = jest.fn();
244
+
245
+ let clock = Date.now();
246
+
247
+ /** A content root whose `_site.mdx` publishes `contactEmail`, or none. */
248
+ const contentRoot = mkdtempSync(join(tmpdir(), "ailk-8927-"));
249
+ const priorContentRoot = process.env.AILK_CONTENT_ROOT;
250
+
251
+ function publishContactEmail(email: string | undefined): void {
252
+ writeFileSync(
253
+ join(contentRoot, "_site.mdx"),
254
+ `---\nsiteName: Example\n${email ? `contactEmail: ${email}\n` : ""}---\n`,
255
+ );
256
+ }
257
+
258
+ beforeAll(() => {
259
+ Element.prototype.scrollIntoView = jest.fn();
260
+ });
261
+
262
+ afterAll(() => {
263
+ rmSync(contentRoot, { recursive: true, force: true });
264
+ });
265
+
266
+ beforeEach(() => {
267
+ clock += 16_000;
268
+ jest.spyOn(Date, "now").mockImplementation(() => clock);
269
+ global.fetch = fetchMock;
270
+ fetchMock.mockClear();
271
+ answers.length = 0;
272
+ answer = shown();
273
+ getPageSections.mockResolvedValue(authoredPage);
274
+ });
275
+
276
+ afterEach(() => {
277
+ jest.restoreAllMocks();
278
+ });
279
+
280
+ async function renderPricing(
281
+ search: Record<string, string> = {},
282
+ ): Promise<HTMLElement> {
283
+ const element = await PricingPage({
284
+ params: Promise.resolve({ locale: "en" }),
285
+ searchParams: Promise.resolve(search),
286
+ getMdxSource,
287
+ getPageSections,
288
+ });
289
+ let container: HTMLElement | undefined;
290
+ await act(async () => {
291
+ ({ container } = render(element));
292
+ });
293
+ return container!;
294
+ }
295
+
296
+ function tierCards(): HTMLElement[] {
297
+ return screen.queryAllByTestId("pricing-tier-card");
298
+ }
299
+
300
+ function cardNamed(name: string): HTMLElement {
301
+ const card = tierCards().find((c) => within(c).queryByText(name));
302
+ if (!card) throw new Error(`no tier card named ${name}`);
303
+ return card;
304
+ }
305
+
306
+ /** True when `a` comes before `b` in document order. */
307
+ function before(a: Element, b: Element): boolean {
308
+ return (
309
+ (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
310
+ );
311
+ }
312
+
313
+ /** Every JSON-LD node on the page, `@graph` flattened. */
314
+ function jsonLdNodes(container: HTMLElement): Array<Record<string, unknown>> {
315
+ const script = container.querySelector('script[type="application/ld+json"]');
316
+ if (!script) return [];
317
+ const doc = JSON.parse(script.textContent ?? "") as Record<string, unknown>;
318
+ const graph = doc["@graph"];
319
+ return Array.isArray(graph)
320
+ ? (graph as Array<Record<string, unknown>>)
321
+ : [doc];
322
+ }
323
+
324
+ /** Every value of `key` anywhere in `value`. */
325
+ function valuesOf(value: unknown, key: string): unknown[] {
326
+ if (Array.isArray(value)) return value.flatMap((v) => valuesOf(v, key));
327
+ if (value === null || typeof value !== "object") return [];
328
+ return Object.entries(value).flatMap(([k, v]) =>
329
+ k === key ? [v, ...valuesOf(v, key)] : valuesOf(v, key),
330
+ );
331
+ }
332
+
333
+ // ── AC-1 ─────────────────────────────────────────────────────────────────────
334
+
335
+ describe("/pricing on a connected workspace shows its Stripe cards (#8927 AC-1)", () => {
336
+ it("renders one card per shown plan, every visible fact, and the add-on tile", async () => {
337
+ await renderPricing();
338
+
339
+ expect(
340
+ tierCards().map(
341
+ (c) => within(c).getByRole("heading", { level: 3 }).textContent,
342
+ ),
343
+ ).toEqual(["Starter", "Growth", "Scale"]);
344
+ const starter = cardNamed("Starter");
345
+ for (const text of [
346
+ STARTER.description,
347
+ ...STARTER.features,
348
+ STARTER.monthly,
349
+ STARTER.unitLabel,
350
+ STARTER.note,
351
+ STARTER.termsNote,
352
+ ]) {
353
+ expect(within(starter).getByText(text, { exact: false })).toBeTruthy();
354
+ }
355
+ expect(
356
+ within(cardNamed("Growth")).getByText("$49/mo", { exact: false }),
357
+ ).toBeTruthy();
358
+ expect(within(cardNamed("Growth")).getByText("Recommended")).toBeTruthy();
359
+ expect(
360
+ within(cardNamed("Scale")).getByText("$1,500 one-time", { exact: false }),
361
+ ).toBeTruthy();
362
+
363
+ const tiles = screen.getAllByTestId("addon-card");
364
+ expect(tiles).toHaveLength(1);
365
+ for (const text of ["SEO audit", "$15/mo", "per audit"]) {
366
+ expect(within(tiles[0]!).getByText(text, { exact: false })).toBeTruthy();
367
+ }
368
+ expect(tierCards().some((c) => within(c).queryByText("SEO audit"))).toBe(
369
+ false,
370
+ );
371
+
372
+ const body = document.body.textContent ?? "";
373
+ for (const authored of [
374
+ "Authored Basic",
375
+ "Authored Business",
376
+ "Authored add-on",
377
+ "$29",
378
+ ]) {
379
+ expect(body).not.toContain(authored);
380
+ }
381
+
382
+ const hero = screen.getByText("Plans that grow with you");
383
+ const faq = screen.getByText("Can I cancel?");
384
+ expect(before(hero, tierCards()[0]!)).toBe(true);
385
+ expect(before(tiles[0]!, faq)).toBe(true);
386
+ // The authored plans section's copy stays on the Stripe cards.
387
+ expect(screen.getByText("Pick a plan")).toBeTruthy();
388
+
389
+ expect(fetchMock).toHaveBeenCalledTimes(1);
390
+ expect(String(fetchMock.mock.calls[0]![0])).toMatch(
391
+ /\/v1\/content\/shown-offerings$/,
392
+ );
393
+ });
394
+
395
+ it("shows the annual phrase once Annually is pressed", async () => {
396
+ await renderPricing();
397
+ expect(screen.queryByText(STARTER.annual, { exact: false })).toBeNull();
398
+
399
+ await act(async () => {
400
+ fireEvent.click(screen.getByTestId("billing-toggle-annual"));
401
+ });
402
+
403
+ expect(
404
+ within(cardNamed("Starter")).getByText(STARTER.annual, { exact: false }),
405
+ ).toBeTruthy();
406
+ });
407
+
408
+ it("places the plans after the hero and the add-ons after the plans on a page with no pricing section", async () => {
409
+ getPageSections.mockResolvedValue(pageWithoutPricing);
410
+ await renderPricing();
411
+
412
+ const hero = screen.getByText("Plans that grow with you");
413
+ const cards = tierCards();
414
+ const tile = screen.getByTestId("addon-card");
415
+ const faq = screen.getByText("Can I cancel?");
416
+ expect(cards).toHaveLength(3);
417
+ expect(before(hero, cards[0]!)).toBe(true);
418
+ expect(before(cards[2]!, tile)).toBe(true);
419
+ expect(before(tile, faq)).toBe(true);
420
+ // An inserted add-ons section carries its own headline (D4, D6), so the
421
+ // lone tile is never an unlabeled band.
422
+ const addonsHeadline = screen.getByText("Add-ons");
423
+ expect(before(cards[2]!, addonsHeadline)).toBe(true);
424
+ expect(before(addonsHeadline, tile)).toBe(true);
425
+ });
426
+ });
427
+
428
+ // ── AC-2 ─────────────────────────────────────────────────────────────────────
429
+
430
+ /** The one tier card the page highlights — Stripe's pick or ?plan='s. */
431
+ function highlightedCard(): HTMLElement {
432
+ const lit = tierCards().filter(
433
+ (c) =>
434
+ within(c).queryByText("Recommended") ||
435
+ within(c).queryByText("Recommended for you"),
436
+ );
437
+ expect(lit).toHaveLength(1);
438
+ return lit[0]!;
439
+ }
440
+
441
+ describe("/pricing ?plan= and a product switched off (#8927 AC-2)", () => {
442
+ it("moves the recommendation to the card whose {plan} matches", async () => {
443
+ await renderPricing({ plan: "scale" });
444
+
445
+ const card = highlightedCard();
446
+ expect(within(card).getByText("Scale")).toBeTruthy();
447
+ expect(card.getAttribute("aria-current")).toBe("true");
448
+ expect(within(card).getByText("Recommended for you")).toBeTruthy();
449
+ // One label, not two: the mapper's "Recommended" pill does not ride along
450
+ // with the moved recommendation (D4).
451
+ expect(within(card).queryByText("Recommended")).toBeNull();
452
+ expect(screen.queryByText("Recommended")).toBeNull();
453
+ });
454
+
455
+ it.each(["Scale", "scale_monthly", "enterprise", ""])(
456
+ "keeps the workspace's own recommendation for ?plan=%j",
457
+ async (plan) => {
458
+ await renderPricing({ plan });
459
+
460
+ expect(within(highlightedCard()).getByText("Growth")).toBeTruthy();
461
+ },
462
+ );
463
+
464
+ it("shares one read for 15 seconds and drops a switched-off product on the next read", async () => {
465
+ answers.push(
466
+ shown(),
467
+ shown(wireSet((list) => list.filter((o) => o.plan !== "scale"))),
468
+ );
469
+ await renderPricing();
470
+ clock += 10_000;
471
+ await renderPricing();
472
+ expect(fetchMock).toHaveBeenCalledTimes(1);
473
+
474
+ document.body.innerHTML = "";
475
+ clock += 6_000;
476
+ await renderPricing();
477
+
478
+ expect(fetchMock).toHaveBeenCalledTimes(2);
479
+ expect(tierCards()).toHaveLength(2);
480
+ expect(screen.queryByText("Scale")).toBeNull();
481
+ });
482
+ });
483
+
484
+ // ── AC-3 ─────────────────────────────────────────────────────────────────────
485
+
486
+ const FAILED_READS: Array<[string, Answer]> = [
487
+ ["shown, state expired", shown({ ...wireSet(), state: "expired" })],
488
+ ["shown, state empty", shown({ ...wireSet(), state: "empty" })],
489
+ [
490
+ "shown, no offerings",
491
+ shown({ ...wireSet(), offerings: [], productIds: [] }),
492
+ ],
493
+ [
494
+ "status empty",
495
+ { status: 200, body: { ok: true, data: { status: "empty" } } },
496
+ ],
497
+ ["a 500", { status: 500, body: { ok: false, error: "internal_error" } }],
498
+ ["a 429", { status: 429, body: { ok: false, error: "rate_limited" } }],
499
+ [
500
+ "an unknown status",
501
+ { status: 200, body: { ok: true, data: { status: "maybe" } } },
502
+ ],
503
+ ["a non-JSON body", { status: 200, notJson: true }],
504
+ ["a rejected fetch", { reject: true }],
505
+ ];
506
+
507
+ describe("/pricing shows the contact email when there is nothing to sell (#8927 AC-3)", () => {
508
+ beforeEach(() => {
509
+ process.env.AILK_CONTENT_ROOT = contentRoot;
510
+ publishContactEmail(SITE_EMAIL);
511
+ });
512
+
513
+ afterEach(() => {
514
+ if (priorContentRoot === undefined) delete process.env.AILK_CONTENT_ROOT;
515
+ else process.env.AILK_CONTENT_ROOT = priorContentRoot;
516
+ });
517
+
518
+ it.each(FAILED_READS)(
519
+ "renders the contact email for %s",
520
+ async (_label, failure) => {
521
+ answer = failure;
522
+ const container = await renderPricing();
523
+
524
+ const fallback = screen.getByTestId("contact-fallback");
525
+ expect(within(fallback).getByText("Get in touch")).toBeTruthy();
526
+ expect(fallback.textContent).toContain(SITE_EMAIL);
527
+ expect(screen.queryAllByTestId("pricing-tier-card")).toHaveLength(0);
528
+ expect(screen.queryAllByTestId("addon-card")).toHaveLength(0);
529
+ expect(document.body.textContent).not.toContain("$29");
530
+
531
+ const section = fallback.closest("[data-testid='contact-form']");
532
+ expect(section).not.toBeNull();
533
+ expect(
534
+ before(screen.getByText("Plans that grow with you"), section!),
535
+ ).toBe(true);
536
+ expect(before(section!, screen.getByText("Can I cancel?"))).toBe(true);
537
+
538
+ expect(valuesOf(jsonLdNodes(container), "@type").flat()).not.toContain(
539
+ "Offer",
540
+ );
541
+ },
542
+ );
543
+
544
+ it("says no contact details are published when the site has no email", async () => {
545
+ answer = { status: 200, body: { ok: true, data: { status: "empty" } } };
546
+ publishContactEmail(undefined);
547
+ await renderPricing();
548
+
549
+ expect(screen.getByTestId("contact-fallback-no-email")).toBeTruthy();
550
+ });
551
+ });
552
+
553
+ // ── AC-5 ─────────────────────────────────────────────────────────────────────
554
+
555
+ const OFFERINGS_SOURCES = [
556
+ "apps/web/app/[locale]/pricing/page.tsx",
557
+ "apps/web/lib/offerings-pricing.ts",
558
+ ];
559
+ const REPO_ROOT = resolve(__dirname, "../../../../../..");
560
+
561
+ describe("/pricing keeps Stripe text in its element and out of the metadata (#8927 AC-5)", () => {
562
+ it("serializes a hostile product name into JSON-LD without breaking out of it", async () => {
563
+ answer = shown(
564
+ wireSet((list) =>
565
+ list.map((o) => (o.plan === "growth" ? { ...o, name: HOSTILE } : o)),
566
+ ),
567
+ );
568
+ const container = await renderPricing();
569
+
570
+ const scripts = container.querySelectorAll("script");
571
+ expect(scripts).toHaveLength(1);
572
+ expect(scripts[0]!.getAttribute("data-testid")).toBe("json-ld");
573
+ expect(scripts[0]!.textContent).not.toContain("</script");
574
+ expect(valuesOf(jsonLdNodes(container), "name")).toContain(HOSTILE);
575
+ expect(screen.getByText(HOSTILE)).toBeTruthy();
576
+ });
577
+
578
+ it("keeps every fixture product name out of the page metadata", async () => {
579
+ const metadata = await generateMetadata({
580
+ params: Promise.resolve({ locale: "en" }),
581
+ });
582
+ const fields = JSON.stringify([
583
+ metadata.title,
584
+ metadata.description,
585
+ metadata.openGraph,
586
+ ]);
587
+
588
+ for (const name of [HOSTILE, "Starter", "Growth", "Scale", "SEO audit"]) {
589
+ expect(fields).not.toContain(name);
590
+ }
591
+ });
592
+
593
+ it.each(OFFERINGS_SOURCES)("%s never serializes into __html", (path) => {
594
+ const source = readFileSync(join(REPO_ROOT, path), "utf8");
595
+
596
+ expect(source).not.toContain("__html");
597
+ expect(source).not.toContain("JSON.stringify");
598
+ });
599
+ });