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,189 @@
1
+ /**
2
+ * /[locale]/careers/[slug]/apply on a site without the `signal-collectors`
3
+ * module — the Free apply page (#8849 D7). Lead capture is never-gate, so a
4
+ * posting takes an application on every tier.
5
+ *
6
+ * Where the module is present, its static `careers/[slug]/apply/page.tsx`
7
+ * (#8840 D11, the multi-step walk) wins over this dynamic segment — the
8
+ * `[slug]/[detail]` idiom. Every `action` but `apply` is a 404, so
9
+ * `/careers/<slug>/<anything else>` 404s as before. Core, owned by no module:
10
+ * it imports no `flows/` or module file.
11
+ *
12
+ * The segments are checked before any read (SEC-8849-A): `action` must be
13
+ * `apply` and `slug` must match the apply action's own slug rule, then the
14
+ * posting must exist. The form is the posting page's former `#apply` section,
15
+ * moved without change in behaviour (#8746 D1, D5): the workspace's Job
16
+ * application magnet's field step from `flows.get("job-application")` (the
17
+ * public flow read), else the built-in form, as the shared `LeadCaptureForm`
18
+ * bound to the apply action with the posting's slug as the lead's hidden
19
+ * `job`. When the API says this deployment accepts no lead, the page shows the
20
+ * site's contact email instead. Its two hub reads (capture status, the magnet)
21
+ * are shared across requests for 15 seconds per server instance, as
22
+ * /contact's are (#8731). Full-screen (`isMagnetWalkPath` matches the path),
23
+ * so it draws its own back arrow, and `noindex, follow` like the walk.
24
+ */
25
+ import { flows, leads } from "@working-theory/api-client";
26
+ import { flowRouteMetadata } from "@working-theory/metadata";
27
+ import {
28
+ ContactFallback,
29
+ Container,
30
+ Heading,
31
+ LeadCaptureForm,
32
+ localeHref,
33
+ } from "@working-theory/ui";
34
+ import { NavBackLink } from "@working-theory/ui/chrome";
35
+ import {
36
+ JOB_APPLICATION_FLOW_SLUG,
37
+ type FlowConfig,
38
+ } from "@working-theory/validation";
39
+ import type { Metadata } from "next";
40
+ import { notFound } from "next/navigation";
41
+ import { getTranslations } from "next-intl/server";
42
+ import type { ReactElement } from "react";
43
+
44
+ import { postingTitle } from "../../../../../lib/posting-title";
45
+ import { contactFormText } from "../../../contact/contact-form-text";
46
+ import { readSiteContactEmail } from "../../../contact/site-contact-email";
47
+ import { captureLeadAction } from "../apply.actions";
48
+ import {
49
+ jobApplicationFormFallback,
50
+ jobApplicationFormFromConfig,
51
+ } from "../job-application-form";
52
+
53
+ // #8746 D5 — the page is rendered on every request.
54
+ export const dynamic = "force-dynamic";
55
+
56
+ interface Props {
57
+ params: Promise<{ locale: string; slug: string; action: string }>;
58
+ }
59
+
60
+ /**
61
+ * `apply.actions.ts`'s slug rule, restated: a `"use server"` module may export
62
+ * only async functions.
63
+ */
64
+ const POSTING_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,127}$/;
65
+
66
+ /** How long the two hub reads are shared across requests (/contact's #8731 value). */
67
+ const HUB_READ_TTL_MS = 15_000;
68
+
69
+ const hubReads = new Map<string, { at: number; read: Promise<unknown> }>();
70
+
71
+ /**
72
+ * `read()`'s result, shared per instance for HUB_READ_TTL_MS; concurrent calls
73
+ * share one in-flight read, and a failed read (settled as its fallback) is
74
+ * shared the same way, so an unreachable hub is not retried per view.
75
+ */
76
+ function sharedHubRead<T>(key: string, read: () => Promise<T>): Promise<T> {
77
+ const now = Date.now();
78
+ const hit = hubReads.get(key);
79
+ if (hit && now - hit.at < HUB_READ_TTL_MS) return hit.read as Promise<T>;
80
+ const fresh = read();
81
+ hubReads.set(key, { at: now, read: fresh });
82
+ return fresh;
83
+ }
84
+
85
+ /** Whether the API accepts a lead; any failed read counts as accepting (as /contact). */
86
+ function readAccepting(): Promise<boolean> {
87
+ return sharedHubRead("capture-status", async () => {
88
+ try {
89
+ const res = await leads.captureStatus();
90
+ return res.ok && res.body.ok === true ? res.body.data.contact : true;
91
+ } catch {
92
+ return true;
93
+ }
94
+ });
95
+ }
96
+
97
+ /** The served Job application magnet's config, or `null` for any failed read. */
98
+ function readJobApplicationMagnet(): Promise<FlowConfig | null> {
99
+ return sharedHubRead("job-application-magnet", async () => {
100
+ try {
101
+ const res = await flows.get(JOB_APPLICATION_FLOW_SLUG);
102
+ return res.ok ? res.body : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ });
107
+ }
108
+
109
+ /** The posting's title, or `null` for a segment pair this page does not serve. */
110
+ async function titleFor(
111
+ slug: string,
112
+ action: string,
113
+ locale: string,
114
+ ): Promise<string | null> {
115
+ if (action !== "apply" || !POSTING_SLUG_RE.test(slug)) return null;
116
+ return postingTitle(slug, locale);
117
+ }
118
+
119
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
120
+ const { locale, slug, action } = await params;
121
+ const title = await titleFor(slug, action, locale);
122
+ return title === null ? flowRouteMetadata() : flowRouteMetadata({ title });
123
+ }
124
+
125
+ export default async function Page({ params }: Props): Promise<ReactElement> {
126
+ const { locale, slug, action } = await params;
127
+ const title = await titleFor(slug, action, locale);
128
+ if (title === null) notFound();
129
+
130
+ const [accepting, t, tCareers, tFlow, magnet] = await Promise.all([
131
+ readAccepting(),
132
+ getTranslations({ locale, namespace: "contact" }),
133
+ getTranslations({ locale, namespace: "careers" }),
134
+ getTranslations({ locale, namespace: "flow" }),
135
+ readJobApplicationMagnet(),
136
+ ]);
137
+ const text = contactFormText(t);
138
+ const contactEmail = readSiteContactEmail();
139
+ const view =
140
+ (magnet ? jobApplicationFormFromConfig(magnet, locale) : null) ??
141
+ jobApplicationFormFallback(locale);
142
+
143
+ return (
144
+ <main id="main" data-testid="job-apply">
145
+ <div className="px-control-x pt-control-y">
146
+ <NavBackLink
147
+ href={localeHref(locale, `/careers/${slug}`)}
148
+ label={tFlow("back")}
149
+ />
150
+ </div>
151
+ <Container className="py-section">
152
+ <div className="max-w-container-sm">
153
+ <Heading level={1} className="mb-block-tight">
154
+ {title}
155
+ </Heading>
156
+ {accepting ? (
157
+ <LeadCaptureForm
158
+ action={captureLeadAction.bind(null, slug)}
159
+ text={
160
+ view.consentStatement
161
+ ? { ...text, consentStatement: view.consentStatement }
162
+ : text
163
+ }
164
+ termsHref={localeHref(locale, "/terms")}
165
+ policies={{
166
+ terms: localeHref(locale, "/terms"),
167
+ privacy: localeHref(locale, "/privacy"),
168
+ working_agreement: localeHref(locale, "/working-agreement"),
169
+ }}
170
+ fallbackEmail={contactEmail}
171
+ fields={view.fields}
172
+ submitLabel={view.submitLabel}
173
+ successMessage={tCareers("applicationSuccess")}
174
+ />
175
+ ) : (
176
+ <ContactFallback
177
+ email={contactEmail}
178
+ text={{
179
+ heading: text.unavailableHeading,
180
+ body: text.unavailableBody,
181
+ noEmail: text.unavailableNoEmail,
182
+ }}
183
+ />
184
+ )}
185
+ </div>
186
+ </Container>
187
+ </main>
188
+ );
189
+ }
@@ -21,7 +21,9 @@
21
21
  * them), and `job`. `name`, `job` and the read fields are spread after the
22
22
  * extras, so no form field overrides them. The write goes through the site's
23
23
  * lead writer (`src/lib/lead-write.ts`), which adds `payload.site` and
24
- * `payload.page` ("careers") and sends no `siteId`.
24
+ * `payload.page` ("careers") and sends no `siteId`. An accepted post tells
25
+ * the API so (`consent: true`, #9130): the hub requires it of a
26
+ * `flow:job-application` lead and records `payload.consent` itself.
25
27
  *
26
28
  * Checks, before any write: the honeypot (a filled one answers success and
27
29
  * sends nothing); first name, last name and email required; a LinkedIn URL,
@@ -281,7 +283,7 @@ export async function captureLeadAction(
281
283
  }
282
284
 
283
285
  const res = await writeLead(
284
- { source: SOURCE, email, page: "careers", payload },
286
+ { source: SOURCE, email, page: "careers", consent: true, payload },
285
287
  init,
286
288
  );
287
289
  if (!res.ok) {
@@ -12,7 +12,9 @@
12
12
  * other field runs full width. `consent` and `address` fields are not
13
13
  * rendered: the block draws its own terms box, labelled with the step's first
14
14
  * `consent` field's label when it has one (as /contact does). The step's
15
- * `copy.title` heads the section and its `copy.cta` is the submit label.
15
+ * `copy.title` heads the section and its `copy.cta` is the submit label — or,
16
+ * when step 1 has none, the last field step's (#8840 D3: the two-step seed
17
+ * carries "Submit application" on step 2 only).
16
18
  *
17
19
  * Copy resolves with the one LocalizedString rule every flow surface shares
18
20
  * (`resolveLocalizedString`). Pure: no fetch, no React.
@@ -30,7 +32,7 @@ export interface JobApplicationFormView {
30
32
  fields: ContactFormField[];
31
33
  /** The step's first consent field's label — `[Label](key)` tokens intact. */
32
34
  consentStatement?: string;
33
- /** The step's `copy.cta`. */
35
+ /** The step's `copy.cta`, else the last field step's (#8840 D3). */
34
36
  submitLabel?: string;
35
37
  }
36
38
 
@@ -96,13 +98,16 @@ export function jobApplicationFormFromConfig(
96
98
  }
97
99
 
98
100
  const consent = step.fields.find((f) => f.inputType === "consent");
101
+ const cta =
102
+ step.copy.cta ??
103
+ config.steps.filter((s): s is FieldStep => s.type === "field").at(-1)?.copy.cta;
99
104
  return {
100
105
  title: resolve(step.copy.title),
101
106
  fields,
102
107
  ...(consent !== undefined && {
103
108
  consentStatement: resolve(consent.copy.label),
104
109
  }),
105
- ...(step.copy.cta !== undefined && { submitLabel: resolve(step.copy.cta) }),
110
+ ...(cta !== undefined && { submitLabel: resolve(cta) }),
106
111
  };
107
112
  }
108
113
 
@@ -2,99 +2,24 @@
2
2
  // next-mdx-remote/rsc, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
3
3
  // Careers list at /careers links here; each job posting lives at /careers/<slug>.
4
4
  //
5
- // Under the posting, an `#apply` section (#8746 D1, D5) draws the workspace's
6
- // Job application magnet the way /contact draws its Contact form: the field
7
- // step of `flows.get("job-application")` (the public flow read — the API
8
- // resolves the workspace), else the built-in form, which is the seeded config
9
- // itself, for a magnet that is absent, off (draft), unreadable or unusable. It
10
- // is the shared `LeadCaptureForm` bound to this page's apply action with the
11
- // posting's slug as the lead's hidden `job`; a submission is a
12
- // `flow:job-application` lead. When the API says this deployment accepts no
13
- // lead, the section shows the site's contact email instead. The form's copy is
14
- // the `contact` catalog's, but for its success line, the `careers` catalog's
15
- // `applicationSuccess`. The page is rendered on every request, and its two hub
16
- // reads (capture status, the magnet) are shared across requests for 15 seconds
17
- // per server instance, as /contact's are (#8731): a burst of views is one hub
18
- // read of each, and an Admin edit shows within 15 seconds. An unknown posting
19
- // 404s before any read.
20
- import { flows, leads } from "@working-theory/api-client";
5
+ // Under the posting, inside <main>, an Apply button (#8849 D8) opens
6
+ // /careers/<slug>/apply — the multi-step walk where the `signal-collectors`
7
+ // module is present (#8840 D11), else the Free apply page,
8
+ // careers/[slug]/[action] (#8849 D7), which draws the one-step application
9
+ // form this page used to draw under `#apply`. The posting's JSON-LD
10
+ // ApplyAction targets the same URL (#8849 D3). The page makes no hub read.
21
11
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
22
12
  import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
23
- import {
24
- ContactFallback,
25
- Container,
26
- Heading,
27
- LeadCaptureForm,
28
- localeHref,
29
- } from "@working-theory/ui";
30
- import {
31
- JOB_APPLICATION_FLOW_SLUG,
32
- type FlowConfig,
33
- } from "@working-theory/validation";
13
+ import { Button, Container, Link, localeHref } from "@working-theory/ui";
34
14
  import type { Metadata } from "next";
35
15
  import { getTranslations } from "next-intl/server";
36
16
  import type { ReactElement } from "react";
37
17
 
38
- import { contactFormText } from "../../contact/contact-form-text";
39
- import { readSiteContactEmail } from "../../contact/site-contact-email";
40
-
41
- import { captureLeadAction } from "./apply.actions";
42
- import {
43
- jobApplicationFormFallback,
44
- jobApplicationFormFromConfig,
45
- } from "./job-application-form";
46
-
47
- // #8746 D5 — the page is rendered on every request.
48
- export const dynamic = "force-dynamic";
49
-
50
18
  interface Props {
51
19
  params: Promise<{ locale: string; slug: string }>;
52
20
  getMdxSource?: typeof defaultGetMdxSource;
53
21
  }
54
22
 
55
- /** How long the two hub reads are shared across requests (/contact's #8731 value). */
56
- const HUB_READ_TTL_MS = 15_000;
57
-
58
- const hubReads = new Map<string, { at: number; read: Promise<unknown> }>();
59
-
60
- /**
61
- * `read()`'s result, shared per instance for HUB_READ_TTL_MS; concurrent calls
62
- * share one in-flight read, and a failed read (settled as its fallback) is
63
- * shared the same way, so an unreachable hub is not retried per view.
64
- */
65
- function sharedHubRead<T>(key: string, read: () => Promise<T>): Promise<T> {
66
- const now = Date.now();
67
- const hit = hubReads.get(key);
68
- if (hit && now - hit.at < HUB_READ_TTL_MS) return hit.read as Promise<T>;
69
- const fresh = read();
70
- hubReads.set(key, { at: now, read: fresh });
71
- return fresh;
72
- }
73
-
74
- /** Whether the API accepts a lead; any failed read counts as accepting (as /contact). */
75
- function readAccepting(): Promise<boolean> {
76
- return sharedHubRead("capture-status", async () => {
77
- try {
78
- const res = await leads.captureStatus();
79
- return res.ok && res.body.ok === true ? res.body.data.contact : true;
80
- } catch {
81
- return true;
82
- }
83
- });
84
- }
85
-
86
- /** The served Job application magnet's config, or `null` for any failed read. */
87
- function readJobApplicationMagnet(): Promise<FlowConfig | null> {
88
- return sharedHubRead("job-application-magnet", async () => {
89
- try {
90
- const res = await flows.get(JOB_APPLICATION_FLOW_SLUG);
91
- return res.ok ? res.body : null;
92
- } catch {
93
- return null;
94
- }
95
- });
96
- }
97
-
98
23
  export async function generateMetadata({
99
24
  params,
100
25
  }: Pick<Props, "params">): Promise<Metadata> {
@@ -107,72 +32,25 @@ export default async function Page({
107
32
  getMdxSource,
108
33
  }: Props): Promise<ReactElement> {
109
34
  const { locale, slug } = await params;
110
- const rendered = await renderPage({
35
+ const t = await getTranslations({ locale, namespace: "careers" });
36
+ return renderPage({
111
37
  type: "JobPosting",
112
38
  slug,
113
39
  locale,
114
40
  bodyMode: "mdx-body",
41
+ trailing: (
42
+ <Container className="pb-section">
43
+ <Button asChild size="big">
44
+ <Link
45
+ href={localeHref(locale, `/careers/${slug}/apply`)}
46
+ variant="bare"
47
+ data-testid="job-apply-cta"
48
+ >
49
+ {t("apply")}
50
+ </Link>
51
+ </Button>
52
+ </Container>
53
+ ),
115
54
  ...(getMdxSource !== undefined && { getMdxSource }),
116
55
  });
117
-
118
- const [accepting, t, tCareers, magnet] = await Promise.all([
119
- readAccepting(),
120
- getTranslations({ locale, namespace: "contact" }),
121
- getTranslations({ locale, namespace: "careers" }),
122
- readJobApplicationMagnet(),
123
- ]);
124
- const text = contactFormText(t);
125
- const contactEmail = readSiteContactEmail();
126
- const view =
127
- (magnet ? jobApplicationFormFromConfig(magnet, locale) : null) ??
128
- jobApplicationFormFallback(locale);
129
-
130
- return (
131
- <>
132
- {rendered}
133
- <section
134
- id="apply"
135
- aria-labelledby="apply-heading"
136
- className="scroll-mt-24"
137
- data-testid="job-apply"
138
- >
139
- <Container className="pb-section">
140
- <div className="max-w-container-sm">
141
- <Heading level={2} id="apply-heading" className="mb-block-tight">
142
- {view.title}
143
- </Heading>
144
- {accepting ? (
145
- <LeadCaptureForm
146
- action={captureLeadAction.bind(null, slug)}
147
- text={
148
- view.consentStatement
149
- ? { ...text, consentStatement: view.consentStatement }
150
- : text
151
- }
152
- termsHref={localeHref(locale, "/terms")}
153
- policies={{
154
- terms: localeHref(locale, "/terms"),
155
- privacy: localeHref(locale, "/privacy"),
156
- working_agreement: localeHref(locale, "/working-agreement"),
157
- }}
158
- fallbackEmail={contactEmail}
159
- fields={view.fields}
160
- submitLabel={view.submitLabel}
161
- successMessage={tCareers("applicationSuccess")}
162
- />
163
- ) : (
164
- <ContactFallback
165
- email={contactEmail}
166
- text={{
167
- heading: text.unavailableHeading,
168
- body: text.unavailableBody,
169
- noEmail: text.unavailableNoEmail,
170
- }}
171
- />
172
- )}
173
- </div>
174
- </Container>
175
- </section>
176
- </>
177
- );
178
56
  }
@@ -58,6 +58,7 @@ const VALID = {
58
58
  type SubmitBody = {
59
59
  source: string;
60
60
  email: string;
61
+ consent?: boolean;
61
62
  payload: Record<string, unknown>;
62
63
  };
63
64
  const submitted = (): SubmitBody => mockSubmit.mock.calls[0][0] as SubmitBody;
@@ -97,6 +98,14 @@ describe("captureLeadAction on a job page (#8746 AC-5)", () => {
97
98
  expect(body).not.toHaveProperty("siteId");
98
99
  });
99
100
 
101
+ it("sends the accepted terms as the body's top-level consent: true, never inside payload (#9130)", async () => {
102
+ await captureLeadAction(JOB, null, form(VALID));
103
+
104
+ const body = submitted();
105
+ expect(body.consent).toBe(true);
106
+ expect(body.payload).not.toHaveProperty("consent");
107
+ });
108
+
100
109
  it("carries phone and a LinkedIn URL, a bare domain gaining https://", async () => {
101
110
  await captureLeadAction(
102
111
  JOB,
@@ -0,0 +1,172 @@
1
+ /**
2
+ * /careers in sections mode (#8849 D10, AC-4): one generated `items` section
3
+ * from the JobPosting records, first, searchable and filterable by employment
4
+ * type and location, the facets read from the postings' first-class
5
+ * frontmatter fields.
6
+ *
7
+ * `renderPage` is spied on, so each case reads the input the page hands it and
8
+ * resolves the sections through the page's own `getPageSections`.
9
+ */
10
+ import React from "react";
11
+ import { render, screen } from "@testing-library/react";
12
+ import * as pageRenderer from "@working-theory/page-renderer";
13
+
14
+ jest.mock("next/navigation", () => ({
15
+ __esModule: true,
16
+ notFound: jest.fn(() => {
17
+ throw new Error("NEXT_NOT_FOUND");
18
+ }),
19
+ }));
20
+
21
+ jest.mock("next-mdx-remote/rsc", () => ({
22
+ __esModule: true,
23
+ MDXRemote: ({ source }: { source: string }) =>
24
+ React.createElement("div", { "data-testid": "mdx-rendered" }, source),
25
+ }));
26
+
27
+ import CareersPage from "../page";
28
+
29
+ type Input = Parameters<typeof pageRenderer.renderPage>[0];
30
+ type Section = Awaited<ReturnType<typeof pageRenderer.getPageSections>>[number];
31
+ type GetMdxSource = typeof pageRenderer.getMdxSource;
32
+ type Source = NonNullable<Awaited<ReturnType<GetMdxSource>>>;
33
+ type Frontmatter = Source["frontmatter"];
34
+
35
+ const SEED: Source = {
36
+ frontmatter: {
37
+ type: "CareersList",
38
+ slug: "careers",
39
+ title: "Careers",
40
+ description: "Open roles.",
41
+ },
42
+ body: "",
43
+ };
44
+
45
+ const HOW_WE_WORK: Section = {
46
+ type: "content-block",
47
+ id: "how-we-work",
48
+ data: { headline: "How we work", body: "Async." },
49
+ };
50
+
51
+ const POSTINGS: Record<string, Frontmatter> = {
52
+ writer: {
53
+ type: "JobPosting",
54
+ slug: "writer",
55
+ title: "Writer",
56
+ description: "Write for us.",
57
+ employmentType: "FULL_TIME",
58
+ remote: true,
59
+ location: "Remote (US)",
60
+ publishedAt: "2026-10-01",
61
+ },
62
+ editor: {
63
+ type: "JobPosting",
64
+ slug: "editor",
65
+ title: "Editor",
66
+ description: "Edit for us.",
67
+ employmentType: "PART_TIME",
68
+ location: "Austin, TX",
69
+ publishedAt: "2026-09-01",
70
+ },
71
+ };
72
+
73
+ function sourceFor(postings: Record<string, Frontmatter>) {
74
+ return jest.fn<ReturnType<GetMdxSource>, Parameters<GetMdxSource>>(
75
+ async (type, slug) => {
76
+ if (type === "CareersList") return SEED;
77
+ const frontmatter = postings[slug];
78
+ return frontmatter ? { frontmatter, body: "" } : null;
79
+ },
80
+ );
81
+ }
82
+
83
+ /** Render /careers and return renderPage's input and its resolved sections. */
84
+ async function renderHub(
85
+ slugs: string[],
86
+ postings: Record<string, Frontmatter> = POSTINGS,
87
+ ): Promise<{ input: Input; sections: Section[] }> {
88
+ const spy = jest.spyOn(pageRenderer, "renderPage");
89
+ await CareersPage({
90
+ params: Promise.resolve({ locale: "en" }),
91
+ getMdxSource: sourceFor(postings),
92
+ getMdxSlugs: jest.fn().mockResolvedValue(slugs),
93
+ getPageSections: jest.fn().mockResolvedValue([HOW_WE_WORK]),
94
+ });
95
+ const input = spy.mock.calls[0]![0];
96
+ spy.mockRestore();
97
+ const sections = await input.getPageSections!(SEED.frontmatter);
98
+ return { input, sections };
99
+ }
100
+
101
+ /** The generated section's data, narrowed to the `items` shape. */
102
+ function generated(sections: Section[]) {
103
+ const first = sections[0]!;
104
+ if (first.type !== "items") throw new Error(`expected items, got ${first.type}`);
105
+ return first.data;
106
+ }
107
+
108
+ describe("/careers renders the generated, filterable grid (#8849 D10, AC-4)", () => {
109
+ it("renders in sections mode, the generated section first", async () => {
110
+ const { input, sections } = await renderHub(["editor", "writer"]);
111
+ expect(input.bodyMode).toBeUndefined();
112
+ expect(input.translationsNamespace).toBe("careers");
113
+ expect(sections.map((section) => section.id)).toEqual([
114
+ "open-positions",
115
+ "how-we-work",
116
+ ]);
117
+ expect(sections[0]!.type).toBe("items");
118
+ });
119
+
120
+ it("builds one card per posting, newest first, with the facets from the frontmatter", async () => {
121
+ const { sections } = await renderHub(["editor", "writer"]);
122
+ const data = generated(sections);
123
+ expect(data.items.map((item) => item.title)).toEqual(["Writer", "Editor"]);
124
+ expect(data.items.map((item) => item.eyebrow)).toEqual([
125
+ "Full-time · Remote (US)",
126
+ "Part-time · Austin, TX",
127
+ ]);
128
+ expect(data.items.map((item) => item.link?.href)).toEqual([
129
+ "/careers/writer",
130
+ "/careers/editor",
131
+ ]);
132
+ expect(data.items.map((item) => item.facets)).toEqual([
133
+ { type: "Full-time", location: "Remote (US)" },
134
+ { type: "Part-time", location: "Austin, TX" },
135
+ ]);
136
+ expect(data.filter?.facets?.map((facet) => facet.key)).toEqual([
137
+ "type",
138
+ "location",
139
+ ]);
140
+ expect(data.filter?.countText.other).toBe("{count} open positions");
141
+ });
142
+
143
+ it("reads no facet from schema.mainEntity", async () => {
144
+ const { sections } = await renderHub(["override"], {
145
+ override: {
146
+ type: "JobPosting",
147
+ slug: "override",
148
+ title: "Override",
149
+ description: "Typed only in the override.",
150
+ schema: { mainEntity: { employmentType: "FULL_TIME" } },
151
+ },
152
+ });
153
+ const item = generated(sections).items[0]!;
154
+ expect(item.facets).toBeUndefined();
155
+ expect(item.eyebrow).toBeUndefined();
156
+ });
157
+
158
+ it("with no postings, renders the declared sections alone", async () => {
159
+ const { sections } = await renderHub([]);
160
+ expect(sections.map((section) => section.id)).toEqual(["how-we-work"]);
161
+ });
162
+
163
+ it("with no seed, renders the careers empty state", async () => {
164
+ const element = await CareersPage({
165
+ params: Promise.resolve({ locale: "en" }),
166
+ getMdxSource: jest.fn().mockResolvedValue(null),
167
+ getMdxSlugs: jest.fn().mockResolvedValue(["writer"]),
168
+ });
169
+ render(element);
170
+ expect(screen.getByTestId("section-empty-state")).toBeTruthy();
171
+ });
172
+ });