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,326 @@
1
+ /**
2
+ * The workspace shown set → the Pricing section's data (#8925, spec
3
+ * docs/specs/8925-map-the-workspace-shown-set-spec.md).
4
+ *
5
+ * One pure function, `offeringsPricing`, turns the Offerings shown set (spec
6
+ * 8876 D4, served by #8924) into two Pricing section fragments: one plan card
7
+ * per `ailkGroup: "plan"` Product and one add-on tile per `"addon"` Product,
8
+ * in the snapshot's order, each keyed by the Product's `{plan}` so `?plan=`
9
+ * (#8842) can match it. #8927 places the fragments into `/pricing`.
10
+ *
11
+ * This is the one place Stripe text crosses into page markup, so contract
12
+ * docs/specs/8879-the-offerings-security-contract-key-spec.md binds it:
13
+ * - T2 — every Stripe string is emitted as a plain text field, with format
14
+ * characters removed; nothing is parsed as HTML or Markdown (D8).
15
+ * - T3 — every link is `OFFERINGS_CHECKOUT_PATH` plus a lookup key that
16
+ * `parseLookupKey` accepts; no href is built from any other field (D5).
17
+ * - §3.7 — `yearly` reads as annual and `once` as one-time at render, never
18
+ * in the key (D2).
19
+ *
20
+ * The module holds no user-visible copy: the CTA label, the recommended label
21
+ * and the period phrasings arrive in `labels` (D9, customer-facing-i18n).
22
+ */
23
+ import {
24
+ parseLookupKey,
25
+ lookupKeyPeriodSchema,
26
+ workspaceShownSetSchema,
27
+ type LookupKeyPeriod,
28
+ type OfferingsPrice,
29
+ type OfferingsProduct,
30
+ type PageSection,
31
+ } from "@working-theory/validation";
32
+
33
+ /** The period words, read by name from their one home (#8876 AC-9). */
34
+ const PERIOD = lookupKeyPeriodSchema.enum;
35
+
36
+ type PricingSectionData = Extract<PageSection, { type: "pricing" }>["data"];
37
+ type PricingPlanCard = PricingSectionData["plans"][number];
38
+ /** `AddonCard` ignores `key`; it is carried so a tile names its `{plan}` too. */
39
+ type PricingAddonTile = NonNullable<PricingSectionData["addons"]>[number] & {
40
+ key: string;
41
+ };
42
+
43
+ export interface OfferingsPricingLabels {
44
+ /** The CTA label on every card and tile. */
45
+ cta: string;
46
+ /** The recommended plan's pill and the recommended add-on's eyebrow. */
47
+ recommended: string;
48
+ /** A formatted amount → its monthly, annual and one-time phrasings. */
49
+ monthly(amount: string): string;
50
+ annual(amount: string): string;
51
+ oneTime(amount: string): string;
52
+ }
53
+
54
+ export interface OfferingsPricing {
55
+ plans: PricingSectionData | null;
56
+ addons: PricingSectionData | null;
57
+ }
58
+
59
+ /** The route every Offerings CTA links to, followed by a grammar key. */
60
+ export const OFFERINGS_CHECKOUT_PATH = "/api/offerings/checkout";
61
+
62
+ /**
63
+ * The CTA target for `lookupKey`, or `null` when the key is outside the
64
+ * grammar. A grammar key is `[a-z0-9_-]` only, so it needs no encoding.
65
+ */
66
+ export function offeringsCheckoutHref(lookupKey: string): string | null {
67
+ return parseLookupKey(lookupKey)
68
+ ? `${OFFERINGS_CHECKOUT_PATH}/${lookupKey}`
69
+ : null;
70
+ }
71
+
72
+ /**
73
+ * Whether `lookupKey` is a grammar key of a Price in a current, non-empty
74
+ * shown set — the keys `/pricing`'s cards are built from (#9051 D4).
75
+ */
76
+ export function isShownLookupKey(
77
+ shownSet: unknown,
78
+ lookupKey: string,
79
+ ): boolean {
80
+ if (!parseLookupKey(lookupKey)) return false;
81
+ const parsed = shownSetSchema.safeParse(shownSet);
82
+ if (!parsed.success) return false;
83
+ const { state, offerings } = parsed.data;
84
+ if (state !== "current" || offerings.length === 0) return false;
85
+ return offerings.some((o) => o.prices.some((p) => p.lookupKey === lookupKey));
86
+ }
87
+
88
+ // Stripe's zero- and three-decimal currencies. The spoke's legacy buy route
89
+ // held the only other copy across apps/web and @working-theory/spoke-checkout,
90
+ // and is retired (#9142). The table stays here, not in that package: a hub and
91
+ // a spoke without commerce carry /pricing but not the package.
92
+ const ZERO_DECIMAL_CURRENCIES = new Set([
93
+ "bif",
94
+ "clp",
95
+ "djf",
96
+ "gnf",
97
+ "jpy",
98
+ "kmf",
99
+ "krw",
100
+ "mga",
101
+ "pyg",
102
+ "rwf",
103
+ "ugx",
104
+ "vnd",
105
+ "vuv",
106
+ "xaf",
107
+ "xof",
108
+ "xpf",
109
+ ]);
110
+ const THREE_DECIMAL_CURRENCIES = new Set(["bhd", "jod", "kwd", "omr", "tnd"]);
111
+
112
+ function currencyExponent(currency: string): number {
113
+ if (ZERO_DECIMAL_CURRENCIES.has(currency)) return 0;
114
+ if (THREE_DECIMAL_CURRENCIES.has(currency)) return 3;
115
+ return 2;
116
+ }
117
+
118
+ /**
119
+ * An amount in Stripe's minor units, formatted for `locale` (D7). A whole
120
+ * amount drops its fraction digits; a locale `Intl` refuses falls back to `en`.
121
+ */
122
+ export function formatMinorUnits(
123
+ amount: number,
124
+ currency: string,
125
+ locale: string,
126
+ ): string {
127
+ const code = currency.toLowerCase();
128
+ const exponent = currencyExponent(code);
129
+ const value = amount / 10 ** exponent;
130
+ const options: Intl.NumberFormatOptions = {
131
+ style: "currency",
132
+ currency: code.toUpperCase(),
133
+ maximumFractionDigits: exponent,
134
+ minimumFractionDigits: Number.isInteger(value) ? 0 : exponent,
135
+ };
136
+ let format: Intl.NumberFormat;
137
+ try {
138
+ format = new Intl.NumberFormat(locale, options);
139
+ } catch {
140
+ format = new Intl.NumberFormat("en", options);
141
+ }
142
+ return format.format(value);
143
+ }
144
+
145
+ // Cc, Cf, Zl, Zp and Cs — the T1 classes. The input was normalized once by
146
+ // #8876, so this changes nothing there; it is the last guard before markup.
147
+ const FORMAT_CHARACTERS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}]/gu;
148
+
149
+ function stripFormatCharacters(text: string, keepNewlines = false): string {
150
+ return text.replace(FORMAT_CHARACTERS, (ch) =>
151
+ keepNewlines && ch === "\n" ? ch : "",
152
+ );
153
+ }
154
+
155
+ function features(product: OfferingsProduct): string[] {
156
+ return product.marketingFeatures
157
+ .map((feature) => stripFormatCharacters(feature))
158
+ .filter((feature) => feature.length > 0);
159
+ }
160
+
161
+ function priceFor(
162
+ product: OfferingsProduct,
163
+ period: LookupKeyPeriod,
164
+ ): OfferingsPrice | undefined {
165
+ return product.prices.find((p) => p.period === period);
166
+ }
167
+
168
+ /** The Price a card shows in monthly mode: `monthly`, else `yearly`, else `once`. */
169
+ function shownPrice(product: OfferingsProduct): OfferingsPrice | undefined {
170
+ return (
171
+ priceFor(product, PERIOD.monthly) ??
172
+ priceFor(product, PERIOD.yearly) ??
173
+ priceFor(product, PERIOD.once)
174
+ );
175
+ }
176
+
177
+ function phrase(
178
+ price: OfferingsPrice,
179
+ locale: string,
180
+ labels: OfferingsPricingLabels,
181
+ ): string {
182
+ const amount = formatMinorUnits(price.amount, price.currency, locale);
183
+ if (price.period === PERIOD.monthly) return labels.monthly(amount);
184
+ if (price.period === PERIOD.yearly) return labels.annual(amount);
185
+ return labels.oneTime(amount);
186
+ }
187
+
188
+ /**
189
+ * Only the first `recommended` Product of a group is highlighted (D4). Each
190
+ * offering's own flag is read; the shown set's top-level `recommended` ids
191
+ * carry the same fact and are deliberately not consulted a second time.
192
+ */
193
+ function firstRecommended(products: OfferingsProduct[]): string | undefined {
194
+ return products.find((p) => p.recommended)?.plan;
195
+ }
196
+
197
+ function planCard(
198
+ product: OfferingsProduct,
199
+ highlighted: boolean,
200
+ locale: string,
201
+ labels: OfferingsPricingLabels,
202
+ ): PricingPlanCard | null {
203
+ const shown = shownPrice(product);
204
+ const href = shown ? offeringsCheckoutHref(shown.lookupKey) : null;
205
+ if (!href) return null;
206
+
207
+ const monthly = priceFor(product, PERIOD.monthly);
208
+ const yearly = priceFor(product, PERIOD.yearly);
209
+ const once = priceFor(product, PERIOD.once);
210
+ const price: PricingPlanCard["price"] = {};
211
+ if (monthly) price.monthly = phrase(monthly, locale, labels);
212
+ if (yearly) price.annual = phrase(yearly, locale, labels);
213
+ if (once && !monthly && !yearly) price.custom = phrase(once, locale, labels);
214
+
215
+ const card: PricingPlanCard = {
216
+ key: product.plan,
217
+ name: stripFormatCharacters(product.name),
218
+ price,
219
+ features: features(product),
220
+ cta: { label: labels.cta, href },
221
+ };
222
+ const description =
223
+ product.description && stripFormatCharacters(product.description, true);
224
+ if (description) card.description = description;
225
+ if (product.unitLabel)
226
+ card.priceUnit = stripFormatCharacters(product.unitLabel);
227
+ if (product.note) card.priceSubhead = stripFormatCharacters(product.note);
228
+ if (product.termsNote)
229
+ card.priceFootnote = stripFormatCharacters(product.termsNote);
230
+ // Annual mode shows the yearly Price only when a monthly one is beside it,
231
+ // so only then does the card need a second link (D5, D6).
232
+ const annualHref =
233
+ monthly && yearly && offeringsCheckoutHref(yearly.lookupKey);
234
+ if (annualHref) card.annualCtaHref = annualHref;
235
+ if (highlighted) {
236
+ card.highlighted = true;
237
+ card.highlightStyle = "pill";
238
+ card.highlightLabel = labels.recommended;
239
+ }
240
+ return card;
241
+ }
242
+
243
+ function addonTile(
244
+ product: OfferingsProduct,
245
+ highlighted: boolean,
246
+ locale: string,
247
+ labels: OfferingsPricingLabels,
248
+ ): PricingAddonTile | null {
249
+ const shown = shownPrice(product);
250
+ const href = shown && offeringsCheckoutHref(shown.lookupKey);
251
+ if (!shown || !href) return null;
252
+
253
+ const tile: PricingAddonTile = {
254
+ key: product.plan,
255
+ name: stripFormatCharacters(product.name),
256
+ price: phrase(shown, locale, labels),
257
+ features: features(product),
258
+ cta: { label: labels.cta, href },
259
+ };
260
+ if (product.unitLabel)
261
+ tile.qualifier = stripFormatCharacters(product.unitLabel);
262
+ if (highlighted) {
263
+ tile.eyebrow = labels.recommended;
264
+ tile.checkTone = "primary";
265
+ }
266
+ return tile;
267
+ }
268
+
269
+ // #8927 D3 — `GET /v1/content/shown-offerings` sends the shown set without
270
+ // `workspaceId` and `organizationId` (spec 8924 D3), which the strict schema
271
+ // requires. Both ids become optional; an id that is present is still
272
+ // validated, every other field is still required and an unknown key is still
273
+ // refused.
274
+ const shownSetSchema = workspaceShownSetSchema.partial({
275
+ workspaceId: true,
276
+ organizationId: true,
277
+ });
278
+
279
+ function isPresent<T>(value: T | null): value is T {
280
+ return value !== null;
281
+ }
282
+
283
+ /**
284
+ * The shown set as Pricing section data, or `null` when there is nothing to
285
+ * show: a value the shown-set schema refuses, a snapshot that is not
286
+ * `current`, or no offerings. Each fragment is `null` when its group is empty.
287
+ */
288
+ export function offeringsPricing(
289
+ shownSet: unknown,
290
+ options: { locale: string; labels: OfferingsPricingLabels },
291
+ ): OfferingsPricing | null {
292
+ const parsed = shownSetSchema.safeParse(shownSet);
293
+ if (!parsed.success) return null;
294
+ const { state, offerings } = parsed.data;
295
+ if (state !== "current" || offerings.length === 0) return null;
296
+ const { locale, labels } = options;
297
+
298
+ const planProducts = offerings.filter((o) => o.ailkGroup === "plan");
299
+ const addonProducts = offerings.filter((o) => o.ailkGroup === "addon");
300
+ const recommendedPlan = firstRecommended(planProducts);
301
+ const recommendedAddon = firstRecommended(addonProducts);
302
+
303
+ const plans = planProducts
304
+ .map((p) => planCard(p, p.plan === recommendedPlan, locale, labels))
305
+ .filter(isPresent);
306
+ const addons = addonProducts
307
+ .map((p) => addonTile(p, p.plan === recommendedAddon, locale, labels))
308
+ .filter(isPresent);
309
+
310
+ return {
311
+ plans:
312
+ plans.length > 0
313
+ ? {
314
+ variant: "three-tiers-with-toggle",
315
+ cardLayout: "centered",
316
+ billingToggle: plans.some(
317
+ (card) =>
318
+ card.price.monthly !== undefined &&
319
+ card.price.annual !== undefined,
320
+ ),
321
+ plans,
322
+ }
323
+ : null,
324
+ addons: addons.length > 0 ? { variant: "addons", plans: [], addons } : null,
325
+ };
326
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The posting a `/careers/<slug>/apply` page applies to, read by its title
3
+ * (#8840 D11). The read sits here, not in a page, so the structured-data walk
4
+ * does not take an apply page for a JobPosting page: the posting's own JSON-LD
5
+ * is `/careers/<slug>`'s. In `lib/` (#8849 D9), not a route directory, so
6
+ * every scaffold carries it: the `signal-collectors` walk and the Free apply
7
+ * page (`careers/[slug]/[action]`) both read it.
8
+ */
9
+ import { getMdxSource } from "@working-theory/page-renderer";
10
+
11
+ /** The posting's title in `locale`, or `null` when no posting has this slug. */
12
+ export async function postingTitle(slug: string, locale: string): Promise<string | null> {
13
+ const source = await getMdxSource("JobPosting", slug, locale);
14
+ if (source === null) return null;
15
+ const title = (source.frontmatter as { title?: unknown }).title;
16
+ return typeof title === "string" && title.trim() !== "" ? title : null;
17
+ }
@@ -29,8 +29,10 @@
29
29
  * `services/consent.ts` stamps with — never from this app's copy of the
30
30
  * registry. It is cached per process: fresh for 60 s, the last good entry
31
31
  * served for up to 10 minutes while a refresh fails, at most one entry per
32
- * enabled locale (SEC-8765-D). No usable entry → `503
33
- * AGREEMENTS_UNAVAILABLE`: no user is created without evidence. The database
32
+ * enabled locale (SEC-8765-D). The read carries this site's `AILK_SPOKE_KEY`
33
+ * (the api-client attaches it), which the hub counts against the key's own
34
+ * budget rather than this instance's egress IP (#8805). No usable entry →
35
+ * `503 AGREEMENTS_UNAVAILABLE`: no user is created without evidence. The database
34
36
  * hooks run INSIDE the transaction and never call the network; they read the
35
37
  * cache only (SEC-8765-I).
36
38
  *
@@ -95,7 +95,26 @@
95
95
  "careers": {
96
96
  "heading": "Careers",
97
97
  "emptyState": "Career listings coming soon.",
98
- "applicationSuccess": "Thank you for applying! We have your application and will be in touch."
98
+ "applicationSuccess": "Thank you for applying! We have your application and will be in touch.",
99
+ "apply": "Apply for this role",
100
+ "openPositions": "Open positions",
101
+ "viewRole": "View role",
102
+ "employmentTypeFullTime": "Full-time",
103
+ "employmentTypePartTime": "Part-time",
104
+ "employmentTypeContractor": "Contract",
105
+ "employmentTypeTemporary": "Temporary",
106
+ "employmentTypeIntern": "Internship",
107
+ "employmentTypeVolunteer": "Volunteer",
108
+ "employmentTypePerDiem": "Per diem",
109
+ "employmentTypeOther": "Other",
110
+ "filterSearch": "Search roles",
111
+ "filterType": "Type",
112
+ "filterAllTypes": "All types",
113
+ "filterLocation": "Location",
114
+ "filterAllLocations": "All locations",
115
+ "filterEmpty": "No open positions match your search.",
116
+ "filterCountOne": "1 open position",
117
+ "filterCountOther": "{count} open positions"
99
118
  },
100
119
  "case-studies": {
101
120
  "heading": "Case Studies",
@@ -127,7 +146,8 @@
127
146
  "sendMessage": "Send message",
128
147
  "successMessage": "Thank you for reaching out! We will be in touch shortly.",
129
148
  "trackInAccount": "Track this in your account",
130
- "consentStatement": "I accept the [Terms](terms), [Privacy Policy](privacy), and [Working Agreement](working_agreement).",
149
+ "consentStatement": "I accept the [Terms](terms) and [Privacy Policy](privacy).",
150
+ "preferEmail": "Prefer email?",
131
151
  "consentRequired": "Please accept the terms to send your message.",
132
152
  "genericError": "Something went wrong, please try again.",
133
153
  "unavailableHeading": "Get in touch",
@@ -145,7 +165,7 @@
145
165
  "emailAck": "Got it.",
146
166
  "emailPrompt": "What's the best email to reach you at?",
147
167
  "consentAck": "Thanks — one last thing.",
148
- "consentPrompt": "Do you accept our Terms, Privacy Policy and Working Agreement? They're linked at the foot of every page.",
168
+ "consentPrompt": "Do you accept our Terms and Privacy Policy? They're linked at the foot of every page.",
149
169
  "consentAccept": "Yes, I accept",
150
170
  "consentDecline": "Not yet",
151
171
  "skipMessage": "No worries — here's our form instead.",
@@ -185,6 +205,9 @@
185
205
  "selectAllThatApply": "Select all that apply",
186
206
  "getStarted": "Get Started",
187
207
  "landingIntro": "A few short questions, then your result — it takes about a minute.",
208
+ "unavailableHeading": "This isn't available right now",
209
+ "unavailableBody": "Email us and we'll help.",
210
+ "unavailableNoEmail": "Please try again later.",
188
211
  "bookCall": "Book a Call",
189
212
  "emailMePlan": "Save plan",
190
213
  "pairingTitle": "Better together",
@@ -265,6 +288,15 @@
265
288
  "heading": "Pricing",
266
289
  "emptyState": "Pricing page content coming soon."
267
290
  },
291
+ "pricingOfferings": {
292
+ "cta": "Get started",
293
+ "recommended": "Recommended",
294
+ "recommendedForYou": "Recommended for you",
295
+ "addonsHeadline": "Add-ons",
296
+ "monthly": "{amount}/mo",
297
+ "annual": "{amount}/yr",
298
+ "oneTime": "{amount} one-time"
299
+ },
268
300
  "purchasePolicy": {
269
301
  "heading": "Purchase Policy",
270
302
  "emptyState": "Purchase policy content coming soon."
@@ -335,6 +367,14 @@
335
367
  "heading": "Book time with me",
336
368
  "description": "Pick a time that works for you.",
337
369
  "headline": "Pick a time that works",
370
+ "bookLength": "Book {minutes} min",
371
+ "meetingHeading": "Schedule a {title} meeting",
372
+ "meetingHeadingFallback": "Schedule a meeting",
373
+ "allMeetingTypes": "← All meeting types",
374
+ "backToSite": "Back",
375
+ "unavailableHeading": "Book a time by email",
376
+ "unavailableBody": "Email {email} to book a time.",
377
+ "unavailableNoEmail": "Online booking isn't available right now.",
338
378
  "confirmed": {
339
379
  "title": "You're booked",
340
380
  "description": "Your call is confirmed and the calendar invite is on its way.",
@@ -384,7 +424,12 @@
384
424
  },
385
425
  "reports": {
386
426
  "heading": "Reports",
387
- "emptyState": "Reports coming soon."
427
+ "emptyState": "Reports coming soon.",
428
+ "gateIntro": "Tell us where to send it, and the report opens right away.",
429
+ "gateCta": "Get the report",
430
+ "openFile": "Open the report",
431
+ "fileByEmail": "The link is on its way to your email.",
432
+ "openExternal": "Open it"
388
433
  },
389
434
  "waitlistDashboard": {
390
435
  "gate": {
@@ -1155,18 +1200,7 @@
1155
1200
  "heading": "Features",
1156
1201
  "emptyState": "Features coming soon."
1157
1202
  },
1158
- "newsletter": {
1159
- "emailLabel": "Email",
1160
- "submit": "Subscribe",
1161
- "sending": "Subscribing…",
1162
- "success": "You're subscribed. Thanks for signing up!",
1163
- "consentStatement": "I accept the [Terms](terms)."
1164
- },
1165
1203
  "unsubscribe": {
1166
- "title": "Unsubscribe",
1167
- "lead": "Stop these emails?",
1168
- "button": "Unsubscribe",
1169
- "done": "You're unsubscribed from {description}.",
1170
1204
  "already": "You were already unsubscribed.",
1171
1205
  "invalid": "This link isn't valid.",
1172
1206
  "unavailable": "Unsubscribing isn't available right now."
@@ -26,8 +26,11 @@
26
26
  import { NextRequest, NextResponse } from "next/server";
27
27
  import createMiddleware from "next-intl/middleware";
28
28
 
29
+ import { isBookingPath } from "./lib/auth-routes";
30
+ import { SCHEDULING_PAGES } from "./lib/modules.generated";
29
31
  import {
30
32
  DEFAULT_SECURITY_HEADERS,
33
+ SCHEDULING_SECURITY_HEADERS,
31
34
  applyHeaders,
32
35
  } from "./security-headers.config";
33
36
 
@@ -91,8 +94,15 @@ export function middleware(request: NextRequest): NextResponse {
91
94
  // Step 2: run next-intl middleware (locale detection + rewrite/redirect)
92
95
  const intlResponse = intlMiddleware(requestWithPathname) as NextResponse;
93
96
 
94
- // Step 3: apply the 7 baseline security headers to the response
95
- applyHeaders(intlResponse.headers, DEFAULT_SECURITY_HEADERS);
97
+ // Step 3: apply the 7 baseline security headers to the response. The booking
98
+ // pages of a site that carries the scheduling module get the same map with
99
+ // Cal.com's origin added to the CSP (#8843 D12); no other page does.
100
+ applyHeaders(
101
+ intlResponse.headers,
102
+ SCHEDULING_PAGES && isBookingPath(request.nextUrl.pathname, enabledLocales)
103
+ ? SCHEDULING_SECURITY_HEADERS
104
+ : DEFAULT_SECURITY_HEADERS,
105
+ );
96
106
 
97
107
  return intlResponse;
98
108
  }
@@ -125,6 +125,23 @@ export function buildHeaders(
125
125
  export const DEFAULT_SECURITY_HEADERS: Readonly<Record<string, string>> =
126
126
  Object.freeze(buildHeaders());
127
127
 
128
+ /**
129
+ * The booking pages' CSP additions (#8843 D12). The scheduling module's
130
+ * `scheduler-embed` section loads Cal.com's script and frames its booker, so
131
+ * these pages — and only these — name the provider origin. `'self'` in
132
+ * `frame-src` keeps same-origin frames working, which `default-src 'self'`
133
+ * allowed until the directive existed.
134
+ */
135
+ export const SCHEDULING_CSP: CspDirectiveMap = Object.freeze({
136
+ "script-src": Object.freeze(["https://app.cal.com"]),
137
+ "connect-src": Object.freeze(["https://app.cal.com"]),
138
+ "frame-src": Object.freeze(["'self'", "https://app.cal.com"]),
139
+ });
140
+
141
+ /** The header map for the booking pages: the default plus `SCHEDULING_CSP`. */
142
+ export const SCHEDULING_SECURITY_HEADERS: Readonly<Record<string, string>> =
143
+ Object.freeze(buildHeaders({ csp: SCHEDULING_CSP }));
144
+
128
145
  /** Write every entry from a header map onto a Headers object. */
129
146
  export function applyHeaders(
130
147
  target: Headers,
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  type: "BlogList"
3
3
  slug: "blog"
4
- title: "Get free blog/docs from Fumadocs"
5
- description: "Or upgrade to Pro and get Fumadocs with our easy-to-use editor."
4
+ title: "{Blog}"
5
+ description: "{One sentence on what you write about}"
6
6
  sections:
7
7
  [
8
8
  {
@@ -13,75 +13,8 @@ sections:
13
13
  "variant": "centered",
14
14
  "gapBelow": "tight",
15
15
  "eyebrow": "Blog",
16
- "headline": "Get free blog/docs from Fumadocs",
17
- "subheadline": "Or upgrade to Pro and get Fumadocs with our easy-to-use editor.",
18
- "primaryCta":
19
- { "label": "Get Fumadocs", "href": "https://www.fumadocs.dev" },
20
- "secondaryCta":
21
- {
22
- "label": "Upgrade to Pro",
23
- "href": "https://ailaunchkit.ai/pricing",
24
- "variant": "outline",
25
- },
26
- },
27
- },
28
- {
29
- "id": "posts",
30
- "type": "blog-list",
31
- "data":
32
- {
33
- "variant": "grid",
34
- "posts":
35
- [
36
- {
37
- "title": "Free Fumadocs Blog: publish your blog and docs with Fumadocs",
38
- "slug": "post-1",
39
- "href": "https://www.fumadocs.dev",
40
- "excerpt": "The Free template ships with Fumadocs, the open-source docs and blog engine. Write in Markdown, keep this design system, and publish from your repo.",
41
- "publishedAt": "2026-01-01",
42
- "readingTime": "5 min",
43
- },
44
- {
45
- "title": "Upgrade to Pro: Fumadocs with our easy-to-use editor",
46
- "slug": "post-2",
47
- "href": "https://ailaunchkit.ai/pricing",
48
- "excerpt": "Upgrade to Pro and write your posts and docs in a visual editor — no Markdown, no repo, the same Fumadocs pages. See what Pro includes.",
49
- "publishedAt": "2026-01-02",
50
- "readingTime": "5 min",
51
- },
52
- {
53
- "title": "Free Fumadocs Blog: publish your blog and docs with Fumadocs",
54
- "slug": "post-3",
55
- "href": "https://www.fumadocs.dev",
56
- "excerpt": "The Free template ships with Fumadocs, the open-source docs and blog engine. Write in Markdown, keep this design system, and publish from your repo.",
57
- "publishedAt": "2026-01-03",
58
- "readingTime": "5 min",
59
- },
60
- {
61
- "title": "Upgrade to Pro: Fumadocs with our easy-to-use editor",
62
- "slug": "post-4",
63
- "href": "https://ailaunchkit.ai/pricing",
64
- "excerpt": "Upgrade to Pro and write your posts and docs in a visual editor — no Markdown, no repo, the same Fumadocs pages. See what Pro includes.",
65
- "publishedAt": "2026-01-04",
66
- "readingTime": "5 min",
67
- },
68
- {
69
- "title": "Free Fumadocs Blog: publish your blog and docs with Fumadocs",
70
- "slug": "post-5",
71
- "href": "https://www.fumadocs.dev",
72
- "excerpt": "The Free template ships with Fumadocs, the open-source docs and blog engine. Write in Markdown, keep this design system, and publish from your repo.",
73
- "publishedAt": "2026-01-05",
74
- "readingTime": "5 min",
75
- },
76
- {
77
- "title": "Upgrade to Pro: Fumadocs with our easy-to-use editor",
78
- "slug": "post-6",
79
- "href": "https://ailaunchkit.ai/pricing",
80
- "excerpt": "Upgrade to Pro and write your posts and docs in a visual editor — no Markdown, no repo, the same Fumadocs pages. See what Pro includes.",
81
- "publishedAt": "2026-01-06",
82
- "readingTime": "5 min",
83
- },
84
- ],
16
+ "headline": "{Blog}",
17
+ "subheadline": "{One sentence on what you write about}",
85
18
  },
86
19
  },
87
20
  {
@@ -20,46 +20,6 @@ sections:
20
20
  "primaryCta": { "label": "{Get in touch}", "href": "/contact" },
21
21
  },
22
22
  },
23
- {
24
- "id": "add-scheduling",
25
- "type": "cta",
26
- "data":
27
- {
28
- "variant": "box",
29
- "headline": "{What booking a call would do for your visitors}",
30
- "description": "{One or two sentences on what live scheduling adds once it's on}",
31
- "primaryCta":
32
- {
33
- "label": "Add scheduling with Pro",
34
- "href": "https://ailaunchkit.ai/pricing",
35
- "external": true,
36
- },
37
- },
38
- },
39
- {
40
- "id": "what-happens-on-the-call",
41
- "type": "items",
42
- "data":
43
- {
44
- "headline": "What happens on the call?",
45
- "columns": 3,
46
- "items":
47
- [
48
- {
49
- "title": "{The first thing you cover}",
50
- "description": "{One or two sentences on what the visitor learns or decides here}",
51
- },
52
- {
53
- "title": "{The second thing you cover}",
54
- "description": "{One or two sentences on what the visitor learns or decides here}",
55
- },
56
- {
57
- "title": "{The next step you agree}",
58
- "description": "{One or two sentences on what happens after the call}",
59
- },
60
- ],
61
- },
62
- },
63
23
  {
64
24
  "id": "back-to-meet",
65
25
  "type": "content-block",
@@ -77,4 +37,4 @@ sections:
77
37
  ]
78
38
  ---
79
39
 
80
- {/* Rendered from frontmatter `sections[]` by the /meet/[slug] placeholder route. A template that carries the scheduling module overrides this file with the live booker. The "What happens on the call?" section moved here verbatim from the retired /book-a-call page (#6897 D10). Placeholder copy — replace every {brace} with your own words before you launch. */}
40
+ {/* Rendered from frontmatter `sections[]` by the /meet/[slug] placeholder route. A site that carries the scheduling module renders the hub's meeting page instead, reading this seed only for its URL, its JSON-LD and its fallback title (#8843 C5). Placeholder copy — replace every {brace} with your own words before you launch. */}