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,601 @@
1
+ /**
2
+ * The shown set → Pricing section data mapper (#8925, spec
3
+ * docs/specs/8925-map-the-workspace-shown-set-spec.md AC-1–AC-4, AC-7).
4
+ *
5
+ * The fixture is parsed with `workspaceShownSetSchema` before use, so it
6
+ * cannot drift from spec 8876 D4. Its offerings are deliberately not in name
7
+ * or price order, so a sort fails AC-1. The labels are sentinels (`⟪`), so
8
+ * any copy the mapper invents shows up in AC-7.
9
+ */
10
+ import { render } from "@testing-library/react";
11
+ import { Pricing } from "@working-theory/ui/sections";
12
+ import {
13
+ parseLookupKey,
14
+ PricingSectionDataSchema,
15
+ workspaceShownSetSchema,
16
+ type OfferingsPrice,
17
+ type OfferingsProduct,
18
+ type WorkspaceShownSet,
19
+ } from "@working-theory/validation";
20
+ import React from "react";
21
+
22
+ import {
23
+ formatMinorUnits,
24
+ OFFERINGS_CHECKOUT_PATH,
25
+ offeringsCheckoutHref,
26
+ offeringsPricing,
27
+ type OfferingsPricing,
28
+ type OfferingsPricingLabels,
29
+ } from "../offerings-pricing";
30
+
31
+ const LABELS: OfferingsPricingLabels = {
32
+ cta: "CTA⟪",
33
+ recommended: "REC⟪",
34
+ monthly: (amount) => "M⟪" + amount,
35
+ annual: (amount) => "Y⟪" + amount,
36
+ oneTime: (amount) => "O⟪" + amount,
37
+ };
38
+
39
+ const OPTIONS = { locale: "en-US", labels: LABELS };
40
+
41
+ const PERIOD_INTERVAL = {
42
+ monthly: "month",
43
+ yearly: "year",
44
+ once: null,
45
+ } as const;
46
+
47
+ function price(
48
+ plan: string,
49
+ period: OfferingsPrice["period"],
50
+ amount: number,
51
+ currency: string,
52
+ ): OfferingsPrice {
53
+ return {
54
+ priceId: `price_${plan.replace(/-/g, "")}${period}`,
55
+ lookupKey: `${plan}_${period}`,
56
+ plan,
57
+ period,
58
+ interval: PERIOD_INTERVAL[period],
59
+ amount,
60
+ currency,
61
+ };
62
+ }
63
+
64
+ function product(
65
+ plan: string,
66
+ ailkGroup: OfferingsProduct["ailkGroup"],
67
+ prices: OfferingsPrice[],
68
+ extra: Partial<OfferingsProduct> = {},
69
+ ): OfferingsProduct {
70
+ return {
71
+ productId: `prod_${plan.replace(/-/g, "")}`,
72
+ plan,
73
+ name: plan.charAt(0).toUpperCase() + plan.slice(1),
74
+ description: null,
75
+ marketingFeatures: [],
76
+ unitLabel: null,
77
+ note: null,
78
+ termsNote: null,
79
+ ailkGroup,
80
+ ailkOrder: null,
81
+ prices,
82
+ recommended: false,
83
+ ...extra,
84
+ };
85
+ }
86
+
87
+ function fixtureOfferings(): OfferingsProduct[] {
88
+ return [
89
+ product("starter", "plan", [price("starter", "yearly", 50000, "jpy")], {
90
+ name: "Starter",
91
+ marketingFeatures: ["One site", "Email support"],
92
+ }),
93
+ product(
94
+ "growth",
95
+ "plan",
96
+ [
97
+ price("growth", "monthly", 4900, "usd"),
98
+ price("growth", "yearly", 49000, "usd"),
99
+ ],
100
+ {
101
+ name: "Growth",
102
+ description: "For teams that ship weekly",
103
+ marketingFeatures: ["Ten sites", "Priority support"],
104
+ unitLabel: "per seat",
105
+ note: "Billed per active seat",
106
+ termsNote: "Cancel any time from your account",
107
+ recommended: true,
108
+ },
109
+ ),
110
+ product(
111
+ "basic",
112
+ "plan",
113
+ [
114
+ price("basic", "once", 150000, "usd"),
115
+ price("basic", "monthly", 1000, "usd"),
116
+ ],
117
+ { name: "Basic" },
118
+ ),
119
+ product(
120
+ "seo-audit",
121
+ "addon",
122
+ [price("seo-audit", "monthly", 1950, "eur")],
123
+ {
124
+ name: "SEO audit",
125
+ marketingFeatures: ["Monthly report"],
126
+ unitLabel: "per site",
127
+ recommended: true,
128
+ },
129
+ ),
130
+ product(
131
+ "audit-pack",
132
+ "addon",
133
+ [
134
+ price("audit-pack", "monthly", 2900, "usd"),
135
+ price("audit-pack", "yearly", 29000, "usd"),
136
+ ],
137
+ { name: "Audit pack" },
138
+ ),
139
+ ];
140
+ }
141
+
142
+ function shownSet(
143
+ edit: (set: WorkspaceShownSet) => void = () => {},
144
+ ): WorkspaceShownSet {
145
+ const offerings = fixtureOfferings();
146
+ const set: WorkspaceShownSet = {
147
+ workspaceId: "wksp_01890a5d-ac96-774b-bcce-b302099a8057",
148
+ organizationId: "org_01890a5d-ac96-774b-bcce-b302099a8058",
149
+ productIds: offerings.map((o) => o.productId),
150
+ recommended: { plan: "prod_growth", addon: "prod_seoaudit" },
151
+ offerings,
152
+ state: "current",
153
+ snapshotAt: "2026-10-18T00:00:00.000Z",
154
+ };
155
+ edit(set);
156
+ return workspaceShownSetSchema.parse(set);
157
+ }
158
+
159
+ function offering(set: WorkspaceShownSet, plan: string): OfferingsProduct {
160
+ const found = set.offerings.find((o) => o.plan === plan);
161
+ if (!found) throw new Error(`no offering ${plan}`);
162
+ return found;
163
+ }
164
+
165
+ function mapped(set: unknown = shownSet()): OfferingsPricing {
166
+ const result = offeringsPricing(set, OPTIONS);
167
+ if (!result) throw new Error("expected a mapping");
168
+ return result;
169
+ }
170
+
171
+ function plansOf(result: OfferingsPricing) {
172
+ if (!result.plans) throw new Error("expected a plans fragment");
173
+ return result.plans.plans;
174
+ }
175
+
176
+ /** The mapper's tiles carry `key` beside the fields `AddonCard` reads (D3). */
177
+ type Tile = NonNullable<
178
+ NonNullable<OfferingsPricing["addons"]>["addons"]
179
+ >[number] & {
180
+ key: string;
181
+ };
182
+
183
+ function addonsOf(result: OfferingsPricing): Tile[] {
184
+ if (!result.addons?.addons) throw new Error("expected an add-ons fragment");
185
+ return result.addons.addons as Tile[];
186
+ }
187
+
188
+ function card(result: OfferingsPricing, key: string) {
189
+ const found = plansOf(result).find((p) => p.key === key);
190
+ if (!found) throw new Error(`no card ${key}`);
191
+ return found;
192
+ }
193
+
194
+ function tile(result: OfferingsPricing, key: string) {
195
+ const found = addonsOf(result).find((t) => t.key === key);
196
+ if (!found) throw new Error(`no tile ${key}`);
197
+ return found;
198
+ }
199
+
200
+ function hrefFor(lookupKey: string): string {
201
+ return `${OFFERINGS_CHECKOUT_PATH}/${lookupKey}`;
202
+ }
203
+
204
+ /** The card's price field → the period of the Price it was formatted from. */
205
+ const FIELD_PERIOD = {
206
+ monthly: "monthly",
207
+ annual: "yearly",
208
+ custom: "once",
209
+ } as const;
210
+
211
+ /** `PricingTierCard`'s own pick of which price field a mode displays. */
212
+ function shownField(
213
+ price: {
214
+ monthly?: string | undefined;
215
+ annual?: string | undefined;
216
+ custom?: string | undefined;
217
+ },
218
+ mode: "monthly" | "annual",
219
+ ): keyof typeof FIELD_PERIOD | undefined {
220
+ const order =
221
+ mode === "annual"
222
+ ? (["annual", "monthly", "custom"] as const)
223
+ : (["monthly", "annual", "custom"] as const);
224
+ return order.find((field) => price[field] !== undefined);
225
+ }
226
+
227
+ describe("offeringsPricing — one card per Product (AC-1)", () => {
228
+ it("keeps snapshot order, splits plans from add-ons and keys each card by {plan}", () => {
229
+ const result = mapped();
230
+ expect(plansOf(result).map((p) => p.key)).toEqual([
231
+ "starter",
232
+ "growth",
233
+ "basic",
234
+ ]);
235
+ expect(addonsOf(result).map((t) => t.key)).toEqual([
236
+ "seo-audit",
237
+ "audit-pack",
238
+ ]);
239
+ expect(result.addons?.plans).toEqual([]);
240
+ expect(result.plans?.variant).toBe("three-tiers-with-toggle");
241
+ expect(result.plans?.cardLayout).toBe("centered");
242
+ expect(result.addons?.variant).toBe("addons");
243
+ });
244
+
245
+ it("highlights the recommended plan and the recommended add-on only", () => {
246
+ const result = mapped();
247
+ const highlighted = plansOf(result).filter((p) => p.highlighted);
248
+ expect(highlighted.map((p) => p.key)).toEqual(["growth"]);
249
+ expect(highlighted[0]).toMatchObject({
250
+ highlighted: true,
251
+ highlightStyle: "pill",
252
+ highlightLabel: "REC⟪",
253
+ });
254
+ const flagged = addonsOf(result).filter((t) => t.eyebrow !== undefined);
255
+ expect(flagged.map((t) => t.key)).toEqual(["seo-audit"]);
256
+ expect(flagged[0]).toMatchObject({ eyebrow: "REC⟪", checkTone: "primary" });
257
+ expect(tile(result, "audit-pack").checkTone).toBeUndefined();
258
+ });
259
+
260
+ it("highlights only the first recommended Product of a group", () => {
261
+ const result = mapped(
262
+ shownSet((set) => {
263
+ offering(set, "basic").recommended = true;
264
+ }),
265
+ );
266
+ expect(
267
+ plansOf(result)
268
+ .filter((p) => p.highlighted)
269
+ .map((p) => p.key),
270
+ ).toEqual(["growth"]);
271
+ expect(card(result, "basic").highlightLabel).toBeUndefined();
272
+ });
273
+ });
274
+
275
+ describe("offeringsPricing — periods, the switch and the fields (AC-2)", () => {
276
+ it("maps yearly to annual and lands unit, note and terms note in their fields", () => {
277
+ const growth = card(mapped(), "growth");
278
+ expect(growth.price).toEqual({ monthly: "M⟪$49", annual: "Y⟪$490" });
279
+ expect(growth.priceUnit).toBe("per seat");
280
+ expect(growth.priceSubhead).toBe("Billed per active seat");
281
+ expect(growth.priceFootnote).toBe("Cancel any time from your account");
282
+ expect(growth.name).toBe("Growth");
283
+ expect(growth.description).toBe("For teams that ship weekly");
284
+ expect(growth.features).toEqual(["Ten sites", "Priority support"]);
285
+ });
286
+
287
+ it("shows a recurring Price over a once Price, and maps once to one-time", () => {
288
+ const basic = card(mapped(), "basic");
289
+ expect(basic.price).toEqual({ monthly: "M⟪$10" });
290
+ expect(basic.cta.href).toBe(hrefFor("basic_monthly"));
291
+
292
+ const onceOnly = card(
293
+ mapped(
294
+ shownSet((set) => {
295
+ const b = offering(set, "basic");
296
+ b.prices = b.prices.filter((p) => p.period !== "monthly");
297
+ }),
298
+ ),
299
+ "basic",
300
+ );
301
+ expect(onceOnly.price).toEqual({ custom: "O⟪$1,500" });
302
+ expect(onceOnly.cta.href).toBe(hrefFor("basic_once"));
303
+ });
304
+
305
+ it("formats a zero-decimal yearly-only price without a monthly figure", () => {
306
+ expect(card(mapped(), "starter").price).toEqual({ annual: "Y⟪¥50,000" });
307
+ });
308
+
309
+ it("shows a yearly Price over a once Price beside it", () => {
310
+ const basic = card(
311
+ mapped(
312
+ shownSet((set) => {
313
+ const b = offering(set, "basic");
314
+ b.prices = [
315
+ price("basic", "once", 150000, "usd"),
316
+ price("basic", "yearly", 10000, "usd"),
317
+ ];
318
+ }),
319
+ ),
320
+ "basic",
321
+ );
322
+ expect(basic.price).toEqual({ annual: "Y⟪$100" });
323
+ expect(basic.cta.href).toBe(hrefFor("basic_yearly"));
324
+ });
325
+
326
+ it("omits the optional fields a Product does not carry", () => {
327
+ const starter = card(mapped(), "starter");
328
+ for (const field of [
329
+ "description",
330
+ "priceUnit",
331
+ "priceSubhead",
332
+ "priceFootnote",
333
+ ]) {
334
+ expect(starter).not.toHaveProperty(field);
335
+ }
336
+ expect(starter).not.toHaveProperty("annualCtaHref");
337
+ });
338
+
339
+ it("produces the switch only when a plan Product has both monthly and yearly", () => {
340
+ expect(mapped().plans?.billingToggle).toBe(true);
341
+ const noTwoPrice = mapped(
342
+ shownSet((set) => {
343
+ const g = offering(set, "growth");
344
+ g.prices = g.prices.filter((p) => p.period !== "yearly");
345
+ }),
346
+ );
347
+ expect(noTwoPrice.plans?.billingToggle).toBe(false);
348
+ });
349
+
350
+ it("formats amounts from minor units with each currency's exponent", () => {
351
+ expect(formatMinorUnits(4950, "usd", "en-US")).toBe("$49.50");
352
+ expect(formatMinorUnits(500, "jpy", "en-US")).toBe("¥500");
353
+ expect(formatMinorUnits(12340, "kwd", "en-US")).toContain("12.340");
354
+ const euro = formatMinorUnits(4900, "eur", "de-DE");
355
+ expect(euro).toContain("49");
356
+ expect(euro).toContain("€");
357
+ });
358
+
359
+ it("falls back to en for a locale Intl refuses", () => {
360
+ expect(formatMinorUnits(4900, "usd", "not a locale!")).toBe("$49");
361
+ });
362
+
363
+ it("links, in each billing mode, the Price that mode displays", () => {
364
+ const set = shownSet();
365
+ const result = mapped(set);
366
+ for (const plan of plansOf(result)) {
367
+ const source = offering(set, plan.key!);
368
+ for (const mode of ["monthly", "annual"] as const) {
369
+ const field = shownField(plan.price, mode);
370
+ expect(field).toBeDefined();
371
+ const expected = hrefFor(`${source.plan}_${FIELD_PERIOD[field!]}`);
372
+ const linked =
373
+ mode === "annual"
374
+ ? (plan.annualCtaHref ?? plan.cta.href)
375
+ : plan.cta.href;
376
+ expect(linked).toBe(expected);
377
+ }
378
+ }
379
+ expect(
380
+ plansOf(result)
381
+ .filter((p) => p.annualCtaHref !== undefined)
382
+ .map((p) => p.key),
383
+ ).toEqual(["growth"]);
384
+ expect(card(result, "growth").annualCtaHref).toBe(hrefFor("growth_yearly"));
385
+ });
386
+
387
+ it("formats an add-on tile's shown price and links its monthly Price", () => {
388
+ const result = mapped();
389
+ const seo = tile(result, "seo-audit");
390
+ expect(seo.price).toBe("M⟪€19.50");
391
+ expect(seo.qualifier).toBe("per site");
392
+ expect(seo.features).toEqual(["Monthly report"]);
393
+ expect(seo.cta?.href).toBe(hrefFor("seo-audit_monthly"));
394
+ expect(tile(result, "audit-pack").cta?.href).toBe(
395
+ hrefFor("audit-pack_monthly"),
396
+ );
397
+ });
398
+ });
399
+
400
+ describe("offeringsPricing — Stripe text stays text; every link is ours (AC-3)", () => {
401
+ const HOSTILE_DESCRIPTION = "Fast <b>bold</b> javascript:alert(1) \u202Eevil";
402
+
403
+ function hostile(): WorkspaceShownSet {
404
+ return shownSet((set) => {
405
+ const g = offering(set, "growth");
406
+ g.description = HOSTILE_DESCRIPTION;
407
+ g.name = "javascript:alert(1)";
408
+ });
409
+ }
410
+
411
+ it("emits a hostile description as plain text with the bidi override removed", () => {
412
+ const growth = card(mapped(hostile()), "growth");
413
+ expect(growth.description).toBe(
414
+ "Fast <b>bold</b> javascript:alert(1) evil",
415
+ );
416
+ expect(growth.name).toBe("javascript:alert(1)");
417
+ });
418
+
419
+ it("removes format characters from every Stripe field, keeping newlines in a description only", () => {
420
+ // U+202E (bidi override), U+200B (zero-width space), U+0007 (bell).
421
+ const dirty = (text: string) => `\u202E${text}\u200B\u0007`;
422
+ const result = mapped(
423
+ shownSet((set) => {
424
+ const g = offering(set, "growth");
425
+ g.name = dirty("Growth");
426
+ g.description = "line one\nline two";
427
+ g.marketingFeatures = [dirty("Ten sites"), "\u200B"];
428
+ g.unitLabel = dirty("per seat");
429
+ g.note = "note\none";
430
+ g.termsNote = dirty("Terms");
431
+ const seo = offering(set, "seo-audit");
432
+ seo.name = dirty("SEO audit");
433
+ seo.marketingFeatures = [dirty("Monthly report")];
434
+ seo.unitLabel = dirty("per site");
435
+ }),
436
+ );
437
+ expect(card(result, "growth")).toMatchObject({
438
+ name: "Growth",
439
+ description: "line one\nline two",
440
+ features: ["Ten sites"],
441
+ priceUnit: "per seat",
442
+ priceSubhead: "noteone",
443
+ priceFootnote: "Terms",
444
+ });
445
+ expect(tile(result, "seo-audit")).toMatchObject({
446
+ name: "SEO audit",
447
+ features: ["Monthly report"],
448
+ qualifier: "per site",
449
+ });
450
+ });
451
+
452
+ it("builds every href from our route and a key parseLookupKey accepts", () => {
453
+ const set = hostile();
454
+ const result = mapped(set);
455
+ const links: Array<{ plan: string; href: string }> = [];
456
+ for (const plan of plansOf(result)) {
457
+ links.push({ plan: plan.key!, href: plan.cta.href });
458
+ if (plan.annualCtaHref)
459
+ links.push({ plan: plan.key!, href: plan.annualCtaHref });
460
+ }
461
+ for (const t of addonsOf(result))
462
+ links.push({ plan: t.key, href: t.cta!.href });
463
+ expect(links).toHaveLength(6);
464
+
465
+ for (const { plan, href } of links) {
466
+ const match = /^\/api\/offerings\/checkout\/(.+)$/.exec(href);
467
+ expect(match).not.toBeNull();
468
+ const key = match![1]!;
469
+ expect(parseLookupKey(key)).not.toBeNull();
470
+ expect(offering(set, plan).prices.map((p) => p.lookupKey)).toContain(key);
471
+ expect(href).not.toContain("javascript");
472
+ expect(href).not.toContain("<");
473
+ }
474
+ });
475
+
476
+ it("answers null from offeringsCheckoutHref for a key outside the grammar", () => {
477
+ expect(offeringsCheckoutHref("growth_monthly")).toBe(
478
+ "/api/offerings/checkout/growth_monthly",
479
+ );
480
+ expect(offeringsCheckoutHref("growth_quarterly")).toBeNull();
481
+ expect(offeringsCheckoutHref("javascript:alert(1)")).toBeNull();
482
+ expect(offeringsCheckoutHref("../admin_monthly")).toBeNull();
483
+ });
484
+
485
+ it("renders through the Pricing section as literal text with no script link", () => {
486
+ const fragment = mapped(hostile()).plans!;
487
+ const { container } = render(
488
+ React.createElement(Pricing, { sectionName: "pricing", data: fragment }),
489
+ );
490
+ expect(container.querySelector("b")).toBeNull();
491
+ expect(container.textContent).toContain("<b>bold</b>");
492
+ expect(container.textContent).not.toContain("\u202E");
493
+ const anchors = Array.from(container.querySelectorAll("a"));
494
+ expect(anchors.length).toBeGreaterThan(0);
495
+ for (const anchor of anchors) {
496
+ expect(anchor.getAttribute("href") ?? "").not.toMatch(/^javascript:/i);
497
+ }
498
+ });
499
+ });
500
+
501
+ describe("offeringsPricing — nothing to show is null (AC-4)", () => {
502
+ it("answers null for an absent, refused, expired, empty or bare shown set", () => {
503
+ expect(offeringsPricing(undefined, OPTIONS)).toBeNull();
504
+
505
+ const refused = JSON.parse(JSON.stringify(shownSet())) as {
506
+ offerings: Array<{ prices: Array<{ lookupKey: string }> }>;
507
+ };
508
+ refused.offerings[1]!.prices[0]!.lookupKey = "growth_quarterly";
509
+ expect(offeringsPricing(refused, OPTIONS)).toBeNull();
510
+
511
+ for (const state of ["expired", "empty"] as const) {
512
+ expect(offeringsPricing({ ...shownSet(), state }, OPTIONS)).toBeNull();
513
+ }
514
+ expect(
515
+ offeringsPricing({ ...shownSet(), offerings: [] }, OPTIONS),
516
+ ).toBeNull();
517
+ });
518
+
519
+ it("answers a null fragment for a group with no Product", () => {
520
+ const plansOnly = mapped(
521
+ shownSet((set) => {
522
+ set.offerings = set.offerings.filter((o) => o.ailkGroup === "plan");
523
+ }),
524
+ );
525
+ expect(plansOnly.addons).toBeNull();
526
+ expect(plansOnly.plans).not.toBeNull();
527
+
528
+ const addonsOnly = mapped(
529
+ shownSet((set) => {
530
+ set.offerings = set.offerings.filter((o) => o.ailkGroup === "addon");
531
+ }),
532
+ );
533
+ expect(addonsOnly.plans).toBeNull();
534
+ expect(addonsOnly.addons).not.toBeNull();
535
+ });
536
+
537
+ it("answers fragments the Pricing section schema accepts", () => {
538
+ const result = mapped();
539
+ for (const fragment of [result.plans, result.addons]) {
540
+ expect(PricingSectionDataSchema.safeParse(fragment).success).toBe(true);
541
+ }
542
+ });
543
+ });
544
+
545
+ describe("offeringsPricing — no copy of its own (AC-7)", () => {
546
+ it("routes every non-Stripe visible string through the caller's labels", () => {
547
+ const result = mapped();
548
+ const visible: string[] = [];
549
+ for (const plan of plansOf(result)) {
550
+ visible.push(
551
+ plan.cta.label,
552
+ ...Object.values(plan.price).filter(
553
+ (p): p is string => p !== undefined,
554
+ ),
555
+ );
556
+ if (plan.highlightLabel) visible.push(plan.highlightLabel);
557
+ }
558
+ for (const t of addonsOf(result)) {
559
+ visible.push(t.cta!.label, t.price);
560
+ if (t.eyebrow) visible.push(t.eyebrow);
561
+ }
562
+ for (const text of visible) expect(text).toContain("⟪");
563
+
564
+ const serialized = JSON.stringify(result);
565
+ for (const english of [
566
+ "Recommended",
567
+ "/mo",
568
+ "/yr",
569
+ "one-time",
570
+ "Get started",
571
+ "Buy",
572
+ ]) {
573
+ expect(serialized).not.toContain(english);
574
+ }
575
+ });
576
+ });
577
+
578
+ describe("offeringsPricing — the route's wire shape (#8927 D3, AC-7)", () => {
579
+ /** The shown set as `GET /v1/content/shown-offerings` sends it: no ids. */
580
+ function wireSet(): Omit<
581
+ WorkspaceShownSet,
582
+ "workspaceId" | "organizationId"
583
+ > {
584
+ const { workspaceId: _w, organizationId: _o, ...wire } = shownSet();
585
+ return wire;
586
+ }
587
+
588
+ it("maps a set without workspaceId and organizationId as it maps the full set", () => {
589
+ const result = offeringsPricing(wireSet(), OPTIONS);
590
+
591
+ expect(result).not.toBeNull();
592
+ expect(result).toEqual(mapped());
593
+ });
594
+
595
+ it("still refuses an unknown top-level key or a malformed id", () => {
596
+ expect(offeringsPricing({ ...wireSet(), extra: true }, OPTIONS)).toBeNull();
597
+ expect(
598
+ offeringsPricing({ ...wireSet(), workspaceId: "not-an-id" }, OPTIONS),
599
+ ).toBeNull();
600
+ });
601
+ });