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
@@ -107,6 +107,17 @@ const VALID_FIELDS = {
107
107
  const INITIAL_STATE = null;
108
108
 
109
109
  describe("captureLeadAction — valid input", () => {
110
+ it("#8847 D7 — the success state carries the posted name, for the ending's {name}", async () => {
111
+ mockSubmit.mockResolvedValueOnce({
112
+ ok: true,
113
+ body: { id: "lead_abc123", persisted: "db" },
114
+ });
115
+
116
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
117
+
118
+ expect(result).toStrictEqual({ kind: "success", name: "Jane Smith" });
119
+ });
120
+
110
121
  it("returns success state when api-client responds ok", async () => {
111
122
  mockSubmit.mockResolvedValueOnce({
112
123
  ok: true,
@@ -270,7 +281,7 @@ describe("captureLeadAction — outcome mapping (#6730)", () => {
270
281
 
271
282
  const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
272
283
 
273
- expect(result).toEqual({ kind: "success" });
284
+ expect(result).toEqual({ kind: "success", name: expect.any(String) });
274
285
  });
275
286
  });
276
287
 
@@ -382,7 +393,7 @@ describe("captureLeadAction — visitor forwarding (#7394)", () => {
382
393
 
383
394
  const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
384
395
 
385
- expect(result).toEqual({ kind: "success" });
396
+ expect(result).toEqual({ kind: "success", name: expect.any(String) });
386
397
  const expected = visitorForwardHeaders(incoming, FORWARD_KEY);
387
398
  expect(expected).toEqual({
388
399
  "x-ailk-visitor-ip": "203.0.113.5",
@@ -397,7 +408,7 @@ describe("captureLeadAction — visitor forwarding (#7394)", () => {
397
408
 
398
409
  const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
399
410
 
400
- expect(result).toEqual({ kind: "success" });
411
+ expect(result).toEqual({ kind: "success", name: expect.any(String) });
401
412
  expect(mockSubmit).toHaveBeenCalledTimes(1);
402
413
  expect(mockSubmit.mock.calls[0]).toHaveLength(1);
403
414
  });
@@ -438,7 +449,7 @@ describe("captureLeadAction — consent and source (#8661 D8)", () => {
438
449
  makeFormData({ ...VALID_FIELDS, source: "flow:contact", subject: "Support" }),
439
450
  );
440
451
 
441
- expect(result).toEqual({ kind: "success" });
452
+ expect(result).toEqual({ kind: "success", name: expect.any(String) });
442
453
  expect(mockSubmit).toHaveBeenCalledTimes(1);
443
454
  const body = mockSubmit.mock.calls[0][0] as {
444
455
  source: string;
@@ -505,7 +516,7 @@ describe("captureLeadAction — consent and source (#8661 D8)", () => {
505
516
  makeFormData({ ...VALID_FIELDS, source: "conversational-intake" }),
506
517
  );
507
518
 
508
- expect(result).toEqual({ kind: "success" });
519
+ expect(result).toEqual({ kind: "success", name: expect.any(String) });
509
520
  const body = mockSubmit.mock.calls[0][0] as { source: string; consent: unknown };
510
521
  expect(body.source).toBe("conversational-intake");
511
522
  expect(body.consent).toBe(true);
@@ -611,6 +622,7 @@ describe("captureLeadAction — the per-visitor limit (#8747)", () => {
611
622
  for (let i = 0; i < 30; i += 1) {
612
623
  expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
613
624
  kind: "success",
625
+ name: expect.any(String),
614
626
  });
615
627
  }
616
628
  expect(mockSubmit).toHaveBeenCalledTimes(30);
@@ -628,6 +640,7 @@ describe("captureLeadAction — the per-visitor limit (#8747)", () => {
628
640
  for (let i = 0; i < 31; i += 1) {
629
641
  expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
630
642
  kind: "success",
643
+ name: expect.any(String),
631
644
  });
632
645
  }
633
646
  expect(mockSubmit).toHaveBeenCalledTimes(31);
@@ -643,6 +656,7 @@ describe("captureLeadAction — the per-visitor limit (#8747)", () => {
643
656
  mockHeaders.mockResolvedValue(visitor("198.51.100.7"));
644
657
  expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
645
658
  kind: "success",
659
+ name: expect.any(String),
646
660
  });
647
661
  });
648
662
  });
@@ -705,6 +719,7 @@ describe("captureLeadAction — the account link (#8757 D8)", () => {
705
719
 
706
720
  expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toEqual({
707
721
  kind: "success",
722
+ name: expect.any(String),
708
723
  next: { href: "/sign-in?next=%2Faccount&email=a%40b.example", label: LABEL },
709
724
  });
710
725
  });
@@ -715,6 +730,7 @@ describe("captureLeadAction — the account link (#8757 D8)", () => {
715
730
 
716
731
  expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toEqual({
717
732
  kind: "success",
733
+ name: expect.any(String),
718
734
  next: { href: "/fr/sign-in?next=%2Ffr%2Faccount&email=a%40b.example", label: LABEL },
719
735
  });
720
736
  });
@@ -724,6 +740,7 @@ describe("captureLeadAction — the account link (#8757 D8)", () => {
724
740
 
725
741
  expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toStrictEqual({
726
742
  kind: "success",
743
+ name: expect.any(String),
727
744
  });
728
745
  });
729
746
 
@@ -733,6 +750,7 @@ describe("captureLeadAction — the account link (#8757 D8)", () => {
733
750
 
734
751
  expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toEqual({
735
752
  kind: "success",
753
+ name: expect.any(String),
736
754
  next: { href: "/api/hub-session/sign-in?next=%2Faccount&email=a%40b.example", label: LABEL },
737
755
  });
738
756
  });
@@ -743,6 +761,7 @@ describe("captureLeadAction — the account link (#8757 D8)", () => {
743
761
 
744
762
  expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toStrictEqual({
745
763
  kind: "success",
764
+ name: expect.any(String),
746
765
  });
747
766
  });
748
767
 
@@ -752,6 +771,7 @@ describe("captureLeadAction — the account link (#8757 D8)", () => {
752
771
 
753
772
  expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toEqual({
754
773
  kind: "success",
774
+ name: expect.any(String),
755
775
  next: { href: "/sign-in?next=%2Faccount&email=a%40b.example", label: LABEL },
756
776
  });
757
777
  });
@@ -761,6 +781,7 @@ describe("captureLeadAction — the account link (#8757 D8)", () => {
761
781
 
762
782
  expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toStrictEqual({
763
783
  kind: "success",
784
+ name: expect.any(String),
764
785
  });
765
786
  });
766
787
  });
@@ -818,6 +839,7 @@ describe("captureLeadAction — a checkout magnet redirects after the write (#87
818
839
 
819
840
  expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
820
841
  kind: "success",
842
+ name: expect.any(String),
821
843
  });
822
844
  expect(mockRedirect).not.toHaveBeenCalled();
823
845
  },
@@ -830,6 +852,7 @@ describe("captureLeadAction — a checkout magnet redirects after the write (#87
830
852
 
831
853
  expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
832
854
  kind: "success",
855
+ name: expect.any(String),
833
856
  });
834
857
  expect(mockSubmit).toHaveBeenCalledTimes(1);
835
858
  expect(mockRedirect).not.toHaveBeenCalled();
@@ -203,7 +203,10 @@ function checkoutOfferingOf(next: unknown): string | null {
203
203
  * the API answered `next: { completion: "invite" }` and this site can build
204
204
  * one, else exactly `{ kind: "success" }`. Never throws.
205
205
  */
206
- async function successState(next: unknown, email: string): Promise<LeadCaptureFormState> {
206
+ async function successState(
207
+ next: unknown,
208
+ email: string,
209
+ ): Promise<Extract<LeadCaptureFormState, { kind: "success" }>> {
207
210
  const parsed = leadSubmitNextSchema.safeParse(next);
208
211
  if (!parsed.success || parsed.data.completion !== "invite") return { kind: "success" };
209
212
  let locale = "en";
@@ -321,5 +324,6 @@ export async function captureLeadAction(
321
324
  // #8757 D11 — after the write, outside any try: redirect() throws by design.
322
325
  const checkoutOffering = checkoutOfferingOf(res.body.next);
323
326
  if (checkoutOffering !== null) redirect(`/api/checkout/${checkoutOffering}`);
324
- return successState(res.body.next, email);
327
+ // #8847 D7 — the parsed name, which the magnet's ending fills `{name}` with.
328
+ return { ...(await successState(res.body.next, email)), name };
325
329
  }
@@ -11,22 +11,25 @@
11
11
  // API, an older API without the status route, or any other failed read is
12
12
  // treated as accepting: the form renders, and its submit reports honestly.
13
13
  //
14
- // The form's fields (#8661 D6), first usable wins: the workspace's Contact
15
- // form magnet (`flows.get("contact")`, the public flow read — the API resolves
16
- // the workspace, the site alias keyless and a spoke by its key); else the
17
- // page's `contact-form` section fields when it declares any; else the
18
- // built-in form: the seeded magnet's own config, its name, email and message
19
- // labels from the `contact` catalog. The submit label is the magnet's, else
20
- // the section's; the success message is the section's. A magnet-driven
21
- // post is stamped `source: "flow:contact"`, so it is an Inbox submission of
22
- // the Contact form; the other two post as `contact-form` (#8747). The page is rendered on
23
- // every request, but its two hub reads (the magnet and the capture status) are
24
- // shared across requests for HUB_READ_TTL_MS (#8731): a visitor view carries no
25
- // forwarding headers, so on a spoke every view would otherwise count against
26
- // the spoke server's one per-IP limit at the hub. An Admin edit shows within
27
- // that window. The terms statement
28
- // links Terms, Privacy Policy and Working Agreement from a page-built map —
29
- // a magnet label can name a link key, never an href.
14
+ // The form (#8661 D6, #8847 D7), first usable wins: the workspace's Contact
15
+ // form magnet (`flows.get("contact")`, the public flow read — the API
16
+ // resolves the workspace, the site alias keyless and a spoke by its key);
17
+ // else the page's `contact-form` section fields when it declares any; else
18
+ // the built-in form: the seeded magnet's own config, its name, email and
19
+ // message labels from the `contact` catalog. Every visible fact on the page
20
+ // comes from the magnet (#8847): its field step's title is the page's H1 and
21
+ // `<title>`, its body the line under it (`framedSources`), and its ending
22
+ // the success message. The built-in config stands in when the hub cannot be
23
+ // read, so a fresh deploy looks the same either way. The section's own
24
+ // `successMessage` and `submitLabel` apply only when its own fields are
25
+ // used. A magnet-driven post is stamped `source: "flow:contact"`, so it is
26
+ // an Inbox submission of the Contact form; the other two post as
27
+ // `contact-form` (#8747). The page is rendered on every request, but its two
28
+ // hub reads (the magnet and the capture status) are shared across requests
29
+ // for HUB_READ_TTL_MS (`lib/in-page-magnet.ts`, #8731). The terms statement
30
+ // links Terms and Privacy Policy from a page-built map — a magnet label can
31
+ // name a link key, never an href. Under the form, "Prefer email?" and the
32
+ // `_site.mdx` contact email, when the site has one.
30
33
  //
31
34
  // A `contact-form` section in the `column` variant (the minimalism Contact
32
35
  // page, #8661 D12) gets the stacked form look and a Send that stays enabled,
@@ -35,7 +38,6 @@
35
38
  // The chat-style intake (ConversationalIntakeWrapper, #4534) remains
36
39
  // available to a site that prefers it. JSON-LD and translations handled by
37
40
  // renderPage.
38
- import { flows, leads } from "@working-theory/api-client";
39
41
  import type {
40
42
  getMdxSource as defaultGetMdxSource,
41
43
  getPageSections as defaultGetPageSections,
@@ -53,22 +55,30 @@ import {
53
55
  type LeadCaptureFormText,
54
56
  localeHref,
55
57
  } from "@working-theory/ui";
58
+ import { Link, Text } from "@working-theory/ui/primitives";
56
59
  import {
57
60
  CONTACT_FORM_FLOW_SLUG,
58
61
  contactFormFlowConfig,
59
62
  flowLeadSource,
60
- type FlowConfig,
61
63
  } from "@working-theory/validation";
62
64
  import type { Metadata } from "next";
63
65
  import { getTranslations } from "next-intl/server";
64
66
  import type { ReactElement } from "react";
65
67
 
66
- import { contactFormFromConfig } from "./contact-form-magnet";
68
+ import {
69
+ framedSources,
70
+ inPageView,
71
+ readCaptureStatus,
72
+ readServedMagnet,
73
+ type InPageView,
74
+ } from "../../../lib/in-page-magnet";
75
+
76
+ import { contactFormFromConfig, type ContactFormView } from "./contact-form-magnet";
67
77
  import { contactFormText } from "./contact-form-text";
68
78
  import { captureLeadAction } from "./contact.actions";
69
79
  import { readSiteContactEmail } from "./site-contact-email";
70
80
 
71
- // #8661 D6 — rendered on every request; the magnet read behind it is shared for HUB_READ_TTL_MS (#8731).
81
+ // #8661 D6 — rendered on every request; the magnet read behind it is shared for HUB_READ_TTL_MS (#8731, lib/in-page-magnet.ts).
72
82
  export const dynamic = "force-dynamic";
73
83
 
74
84
  interface Props {
@@ -77,11 +87,6 @@ interface Props {
77
87
  getPageSections?: typeof defaultGetPageSections;
78
88
  }
79
89
 
80
- type CaptureStatus = Extract<
81
- Awaited<ReturnType<typeof leads.captureStatus>>,
82
- { ok: true }
83
- >["body"]["data"];
84
-
85
90
  type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
86
91
  type ContactFormSection = Extract<Section, { type: "contact-form" }>;
87
92
 
@@ -118,63 +123,43 @@ function builtInFields(text: LeadCaptureFormText, locale: string): ContactFormFi
118
123
  }
119
124
 
120
125
  /**
121
- * How long the two hub reads are shared across requests (#8731). Short, so an
122
- * Admin edit to the magnet shows within seconds; long enough that a burst of
123
- * views makes one read of each, not one per view.
124
- */
125
- const HUB_READ_TTL_MS = 15_000;
126
-
127
- const hubReads = new Map<string, { at: number; read: Promise<unknown> }>();
128
-
129
- /**
130
- * `read()`'s result, shared per instance for HUB_READ_TTL_MS. Concurrent calls
131
- * share one in-flight read. A read that settles to `null` (any failed read,
132
- * see the two readers below) is shared the same way, so a burst against an
133
- * unreachable hub is not a burst of retries.
134
- */
135
- function sharedHubRead<T>(key: string, read: () => Promise<T>): Promise<T> {
136
- const now = Date.now();
137
- const hit = hubReads.get(key);
138
- if (hit && now - hit.at < HUB_READ_TTL_MS) return hit.read as Promise<T>;
139
- const fresh = read();
140
- hubReads.set(key, { at: now, read: fresh });
141
- return fresh;
142
- }
143
-
144
- /** The API's capture status, or `null` for any failed read (see header). */
145
- function readCaptureStatus(): Promise<CaptureStatus | null> {
146
- return sharedHubRead("capture-status", async () => {
147
- try {
148
- const res = await leads.captureStatus();
149
- return res.ok && res.body.ok === true ? res.body.data : null;
150
- } catch {
151
- return null;
152
- }
153
- });
154
- }
155
-
156
- /**
157
- * The served Contact form magnet's config, or `null` for any failed read: a
158
- * non-2xx (absent, draft, a build with no flow route) or a throw. The
159
- * property access sits inside the `try`, so a client with no `flows` is a
160
- * failed read too.
126
+ * The page's view of the Contact form (#8847 D7): the served magnet's when it
127
+ * maps, else the built-in config's. `served` says which.
161
128
  */
162
- function readContactMagnet(): Promise<FlowConfig | null> {
163
- return sharedHubRead("contact-magnet", async () => {
164
- try {
165
- const res = await flows.get(CONTACT_FORM_FLOW_SLUG);
166
- return res.ok ? res.body : null;
167
- } catch {
168
- return null;
169
- }
170
- });
129
+ async function contactView(
130
+ locale: string,
131
+ fallbackHeadline: string,
132
+ ): Promise<{ view: InPageView; served: boolean }> {
133
+ const magnet = await readServedMagnet(CONTACT_FORM_FLOW_SLUG);
134
+ const served = magnet ? inPageView(magnet, locale, "contact", fallbackHeadline) : null;
135
+ if (served) return { view: served, served: true };
136
+ // The built-in config always maps: its first step has name and email.
137
+ return {
138
+ view: inPageView(contactFormFlowConfig, locale, "contact", fallbackHeadline)!,
139
+ served: false,
140
+ };
171
141
  }
172
142
 
173
143
  export async function generateMetadata({
174
144
  params,
175
- }: Pick<Props, "params">): Promise<Metadata> {
145
+ getMdxSource,
146
+ getPageSections,
147
+ }: Props): Promise<Metadata> {
176
148
  const { locale } = await params;
177
- return renderPageMetadata({ type: "ContactPage", slug: "contact", locale });
149
+ const t = await getTranslations({ locale, namespace: "contact" });
150
+ const { view } = await contactView(locale, t("successMessage"));
151
+ // #8847 D7 — the `<title>` is the magnet's step title, like the H1.
152
+ const framed = framedSources({
153
+ getMdxSource: getMdxSource ?? loadMdxSource,
154
+ getPageSections: getPageSections ?? loadPageSections,
155
+ view,
156
+ });
157
+ return renderPageMetadata({
158
+ type: "ContactPage",
159
+ slug: "contact",
160
+ locale,
161
+ getMdxSource: framed.getMdxSource,
162
+ });
178
163
  }
179
164
 
180
165
  export default async function Page({
@@ -183,17 +168,22 @@ export default async function Page({
183
168
  getPageSections,
184
169
  }: Props): Promise<ReactElement> {
185
170
  const { locale } = await params;
186
- const [status, t, magnet] = await Promise.all([
171
+ const [status, t] = await Promise.all([
187
172
  readCaptureStatus(),
188
173
  getTranslations({ locale, namespace: "contact" }),
189
- readContactMagnet(),
190
174
  ]);
191
175
  const accepting = status?.contact ?? true;
192
176
  const contactEmail = readSiteContactEmail();
193
177
  const text = contactFormText(t);
178
+ const { view, served } = await contactView(locale, text.success);
179
+ const framed = framedSources({
180
+ getMdxSource: getMdxSource ?? loadMdxSource,
181
+ getPageSections: getPageSections ?? loadPageSections,
182
+ view,
183
+ });
194
184
 
195
- const source = await (getMdxSource ?? loadMdxSource)("ContactPage", "contact", locale);
196
- const sections = source ? await (getPageSections ?? loadPageSections)(source.frontmatter) : [];
185
+ const source = await framed.getMdxSource("ContactPage", "contact", locale);
186
+ const sections = source ? await framed.getPageSections(source.frontmatter) : [];
197
187
  const formSection = sections.find(
198
188
  (s): s is ContactFormSection => s.type === "contact-form",
199
189
  );
@@ -205,19 +195,23 @@ export default async function Page({
205
195
  (lead?.type === "hero" && lead.data.variant === "split" ? lead.id : undefined);
206
196
 
207
197
  // #8661 D6 — the magnet's fields, else the section's, else the built-in form.
208
- const served = magnet ? contactFormFromConfig(magnet, locale) : null;
209
- const declared = formSection && formSection.data.fields.length > 0 ? formSection : undefined;
210
- const fields: ContactFormField[] =
211
- served?.fields ?? (declared ? sectionFields(declared) : builtInFields(text, locale));
212
- // A magnet's own submit label wins; the section's copy fills what it leaves.
213
- const submitLabel = served?.submitLabel ?? formSection?.data.submitLabel;
214
- const successMessage = formSection?.data.successMessage;
198
+ const form = view.form as ContactFormView;
199
+ const declared =
200
+ !served && formSection && formSection.data.fields.length > 0 ? formSection : undefined;
201
+ const fields: ContactFormField[] = served
202
+ ? form.fields
203
+ : declared
204
+ ? sectionFields(declared)
205
+ : builtInFields(text, locale);
206
+ // #8847 D7 — the section's submit label and success message apply only to
207
+ // its own fields; otherwise the magnet's (or the built-in config's) ending.
208
+ const submitLabel = served ? form.submitLabel : declared?.data.submitLabel;
215
209
  const column = formSection?.data.variant === "column";
216
210
 
217
- const surface = accepting ? (
211
+ const leadForm = accepting ? (
218
212
  <LeadCaptureForm
219
213
  action={captureLeadAction}
220
- text={served?.consentStatement ? { ...text, consentStatement: served.consentStatement } : text}
214
+ text={served && form.consentStatement ? { ...text, consentStatement: form.consentStatement } : text}
221
215
  termsHref={localeHref(locale, "/terms")}
222
216
  policies={{
223
217
  terms: localeHref(locale, "/terms"),
@@ -227,7 +221,7 @@ export default async function Page({
227
221
  fallbackEmail={fallbackEmail}
228
222
  fields={fields}
229
223
  submitLabel={submitLabel}
230
- successMessage={successMessage}
224
+ {...(declared ? { successMessage: declared.data.successMessage } : { ending: view.ending })}
231
225
  {...(served && { source: flowLeadSource(CONTACT_FORM_FLOW_SLUG) })}
232
226
  {...(column && { look: "column" as const, consentGate: "on-submit" as const })}
233
227
  />
@@ -241,14 +235,27 @@ export default async function Page({
241
235
  }}
242
236
  />
243
237
  );
238
+ // #8847 D7 — one muted line under the form; absent with no address.
239
+ const surface = (
240
+ <>
241
+ {leadForm}
242
+ {contactEmail !== undefined && (
243
+ <div className="mt-block-tight text-center" data-testid="contact-prefer-email">
244
+ <Text tone="muted" variant="body-sm">
245
+ {t("preferEmail")} <Link href={`mailto:${contactEmail}`}>{contactEmail}</Link>
246
+ </Text>
247
+ </div>
248
+ )}
249
+ </>
250
+ );
244
251
 
245
252
  const rendered = await renderPage({
246
253
  type: "ContactPage",
247
254
  slug: "contact",
248
255
  locale,
249
256
  translationsNamespace: "contact",
250
- ...(getMdxSource !== undefined && { getMdxSource }),
251
- ...(getPageSections !== undefined && { getPageSections }),
257
+ getMdxSource: framed.getMdxSource,
258
+ getPageSections: framed.getPageSections,
252
259
  ...(slotted !== undefined && { sectionSlots: { [slotted]: surface } }),
253
260
  });
254
261
  return (