create-ailk 0.20.0 → 0.22.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 (222) hide show
  1. package/README.md +21 -1
  2. package/component-catalog.json +220 -8
  3. package/component-catalog.md +21 -9
  4. package/dist/add-module.js +9 -5
  5. package/dist/apply-design.d.ts +9 -0
  6. package/dist/apply-design.js +78 -31
  7. package/dist/derive-architecture.d.ts +8 -1
  8. package/dist/derive-architecture.js +38 -3
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/design-package.js +7 -2
  12. package/dist/lib/design-checks.d.ts +6 -1
  13. package/dist/lib/design-checks.js +23 -2
  14. package/dist/lib/env-catalog.js +5 -1
  15. package/dist/module-architecture.js +4 -2
  16. package/dist/programmatic.js +63 -25
  17. package/dist/site-chrome.d.ts +8 -0
  18. package/dist/site-chrome.js +17 -1
  19. package/dist/site-roster.d.ts +4 -2
  20. package/dist/site-roster.js +7 -5
  21. package/dist/spoke-checkout.d.ts +39 -18
  22. package/dist/spoke-checkout.js +177 -236
  23. package/dist/starter-grid.js +16 -0
  24. package/dist/surfaces.js +30 -14
  25. package/dist/sync-routes.d.ts +28 -0
  26. package/dist/sync-routes.js +124 -1
  27. package/dist/template-record.d.ts +4 -3
  28. package/dist/template-record.js +6 -3
  29. package/dist/topology.js +25 -1
  30. package/dist/update.d.ts +6 -1
  31. package/dist/update.js +125 -18
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/README.md +26 -25
  51. package/templates/apps/api/CLAUDE.md +20 -7
  52. package/templates/apps/api/jest.config.cjs +1 -0
  53. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  54. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  55. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  56. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  57. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  58. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  59. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  60. package/templates/apps/api/src/middleware/auth.ts +13 -6
  61. package/templates/apps/api/src/openapi/spec.ts +30 -3
  62. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  63. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  64. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  65. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  67. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  68. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  69. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  70. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  71. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  73. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  74. package/templates/apps/api/src/server.ts +28 -0
  75. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  76. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  77. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  78. package/templates/apps/api/src/services/consent.ts +95 -0
  79. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  80. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  81. package/templates/apps/api/src/services/person.ts +40 -8
  82. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  83. package/templates/apps/api/src/services/project-listings.ts +6 -1
  84. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  85. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  86. package/templates/apps/mcp/jest.config.cjs +1 -0
  87. package/templates/apps/web/.env.example +14 -0
  88. package/templates/apps/web/CLAUDE.md +139 -3
  89. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  90. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  91. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  92. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  93. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  94. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  95. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  96. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  97. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  98. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  99. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  100. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  102. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  103. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  104. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  105. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  106. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  114. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  115. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  116. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  117. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  119. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  120. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  121. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  122. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  123. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  124. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  125. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  126. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  127. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  128. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  129. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  130. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  131. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  132. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  133. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  134. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  135. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  136. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  137. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
  139. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
  148. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  149. package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
  150. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  151. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  154. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  155. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  156. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  157. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  160. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  161. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  162. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  163. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  164. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  165. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  166. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  167. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  168. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  169. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  170. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  171. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  172. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  173. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  174. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  175. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  176. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  177. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  178. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  179. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  180. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  181. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  182. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  183. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  184. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  185. package/templates/apps/web/auth.ts +28 -2
  186. package/templates/apps/web/jest.config.cjs +1 -0
  187. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  188. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  189. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  190. package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
  191. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
  192. package/templates/apps/web/lib/account-invite.ts +37 -0
  193. package/templates/apps/web/lib/checkout-config.ts +27 -0
  194. package/templates/apps/web/lib/checkout.ts +93 -0
  195. package/templates/apps/web/lib/consent-policies.ts +22 -0
  196. package/templates/apps/web/lib/deck-page.ts +192 -0
  197. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  198. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  199. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  200. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  201. package/templates/apps/web/lib/site-chrome.ts +2 -0
  202. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  203. package/templates/apps/web/messages/en.json +40 -12
  204. package/templates/apps/web/package.json +1 -0
  205. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  206. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  207. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  208. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  209. package/templates/database/CHANGELOG.md +47 -0
  210. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  211. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  212. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  213. package/templates/database/__tests__/seed.test.ts +72 -2
  214. package/templates/database/inbox/schema.prisma +17 -2
  215. package/templates/database/jest.config.cjs +1 -0
  216. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  217. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  218. package/templates/database/package.json +1 -1
  219. package/templates/database/seed.ts +60 -0
  220. package/templates/tools/jest/pin-timezone.cjs +34 -0
  221. package/versions.json +34 -34
  222. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
@@ -0,0 +1,293 @@
1
+ "use server";
2
+
3
+ /**
4
+ * captureLeadAction — the job page's apply form (#8746 D4, D5). Paired, like
5
+ * /contact's action of the same name, with the `ailkCaptureLead` MCP tool: an
6
+ * application is a lead written to `POST /v1/leads/submit`, so an agent
7
+ * applies through the same capture route. A distinct `*Action` name would
8
+ * claim a capability with no MCP twin (`apps/mcp/__tests__/parity.test.ts`).
9
+ *
10
+ * The page binds the posting's slug as the first argument
11
+ * (`captureLeadAction.bind(null, slug)`); it travels as the lead's hidden
12
+ * `job` value and must be a slug. Bound arguments round-trip through the
13
+ * browser, so it is attribution, never authorization.
14
+ *
15
+ * The lead (#8746 AC-5): source `flow:job-application` — always, so it is an
16
+ * Inbox submission of the Job application magnet (a post from the built-in
17
+ * form too: the magnet is seeded into every workspace and never deleted) —
18
+ * the applicant's email, and a payload of `name` (first + last),
19
+ * `first_name`, `last_name`, the optional `phone` and `linkedin_url`, any
20
+ * extra field the owner added to the magnet (bounded as /contact bounds
21
+ * them), and `job`. `name`, `job` and the read fields are spread after the
22
+ * extras, so no form field overrides them. The write goes through the site's
23
+ * lead writer (`src/lib/lead-write.ts`), which adds `payload.site` and
24
+ * `payload.page` ("careers") and sends no `siteId`.
25
+ *
26
+ * Checks, before any write: the honeypot (a filled one answers success and
27
+ * sends nothing); first name, last name and email required; a LinkedIn URL,
28
+ * when given, an http(s) URL — a bare domain gains `https://`, the flow
29
+ * engine's `url` idiom; the terms box ticked, as the form block requires; and
30
+ * the per-visitor limit /contact runs, in /contact's own bucket so a visitor's
31
+ * lead posts stay at 30 per 10 minutes however they are split (skipped when
32
+ * no client address is readable). Outcomes map as /contact's do: the API's
33
+ * `lead_capture_not_configured` or an unreachable API is `unavailable`, any
34
+ * other refusal the generic error.
35
+ */
36
+
37
+ import { visitorForwardHeaders } from "@working-theory/api-client";
38
+ import {
39
+ LEAD_CAPTURE_HONEYPOT,
40
+ type LeadCaptureFormState,
41
+ } from "@working-theory/ui";
42
+ import {
43
+ accountEmailHintSchema,
44
+ flowLeadSource,
45
+ JOB_APPLICATION_FLOW_SLUG,
46
+ leadSubmitNextSchema,
47
+ } from "@working-theory/validation";
48
+ import { cookies, headers } from "next/headers";
49
+ import { getLocale, getTranslations } from "next-intl/server";
50
+ import { z } from "zod";
51
+
52
+ import { accountInviteHref } from "../../../../lib/account-invite";
53
+ import {
54
+ consentVisitorHeader,
55
+ readConsentVisitor,
56
+ } from "../../../../lib/consent-visitor";
57
+ import { writeLead } from "../../../../src/lib/lead-write";
58
+ import {
59
+ checkRateLimit,
60
+ clientIpFromHeaders,
61
+ } from "../../../../src/lib/rate-limit";
62
+
63
+ const GENERIC_ERROR = "Something went wrong, please try again.";
64
+ const RATE_LIMITED =
65
+ "Too many applications from here. Please try again in a few minutes.";
66
+ const CONSENT_REQUIRED = "Please accept the terms to send your application.";
67
+
68
+ const RATE_LIMIT_MAX_CALLS = 30;
69
+ const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
70
+
71
+ const SOURCE = flowLeadSource(JOB_APPLICATION_FLOW_SLUG);
72
+ const JOB_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,127}$/;
73
+ const HAS_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
74
+
75
+ /** Field names the action reads itself, or writes — never copied from the form. */
76
+ const RESERVED_FIELDS = new Set([
77
+ "first_name",
78
+ "last_name",
79
+ "email",
80
+ "phone",
81
+ "linkedin_url",
82
+ "consent",
83
+ "name",
84
+ "job",
85
+ "source",
86
+ LEAD_CAPTURE_HONEYPOT,
87
+ ]);
88
+ const MAX_EXTRA_FIELDS = 20;
89
+ const MAX_EXTRA_FIELD_LENGTH = 2000;
90
+
91
+ const optionalText = (max: number) =>
92
+ z.preprocess(
93
+ (v) => (typeof v === "string" && v.trim() !== "" ? v.trim() : undefined),
94
+ z.string().max(max).optional(),
95
+ );
96
+
97
+ /** A LinkedIn URL: blank is absent; a bare domain gains `https://`; only http(s). */
98
+ const linkedinUrl = z.preprocess(
99
+ (v) => {
100
+ if (typeof v !== "string" || v.trim() === "") return undefined;
101
+ const value = v.trim();
102
+ return HAS_SCHEME_RE.test(value) ? value : `https://${value}`;
103
+ },
104
+ z
105
+ .string()
106
+ .max(2000)
107
+ .refine((value) => {
108
+ try {
109
+ const url = new URL(value);
110
+ return (
111
+ (url.protocol === "https:" || url.protocol === "http:") &&
112
+ url.hostname.includes(".") &&
113
+ // No credentials: `https://linkedin.com@evil.example/` would show
114
+ // the operator a LinkedIn-looking link to another host.
115
+ url.username === "" &&
116
+ url.password === ""
117
+ );
118
+ } catch {
119
+ return false;
120
+ }
121
+ }, "Enter a valid URL.")
122
+ .optional(),
123
+ );
124
+
125
+ const applicationSchema = z.object({
126
+ first_name: z.string().trim().min(1, "Enter your first name.").max(100),
127
+ last_name: z.string().trim().min(1, "Enter your last name.").max(100),
128
+ email: z.string().trim().email("Enter a valid email address.").max(254),
129
+ phone: optionalText(32),
130
+ linkedin_url: linkedinUrl,
131
+ });
132
+
133
+ /** The extra fields, or `null` over the bounds. Files and `$ACTION_…` keys are skipped. */
134
+ function extraFields(formData: FormData): Record<string, string> | null {
135
+ const extra: Record<string, string> = {};
136
+ let count = 0;
137
+ for (const [key, value] of formData.entries()) {
138
+ if (RESERVED_FIELDS.has(key) || key.startsWith("$ACTION")) continue;
139
+ if (typeof value !== "string") continue;
140
+ if (value.length > MAX_EXTRA_FIELD_LENGTH) return null;
141
+ if (!Object.hasOwn(extra, key)) count += 1;
142
+ if (count > MAX_EXTRA_FIELDS) return null;
143
+ extra[key] = value;
144
+ }
145
+ return extra;
146
+ }
147
+
148
+ function isNotConfigured(res: { status: number; body: unknown }): boolean {
149
+ return (
150
+ res.status === 503 &&
151
+ typeof res.body === "object" &&
152
+ res.body !== null &&
153
+ (res.body as { error?: unknown }).error === "lead_capture_not_configured"
154
+ );
155
+ }
156
+
157
+ function isApiUnreachable(res: { status: number }): boolean {
158
+ return res.status === 0 || res.status === 502 || res.status === 504;
159
+ }
160
+
161
+ const field = (formData: FormData, name: string): unknown =>
162
+ formData.get(name) ?? undefined;
163
+
164
+ /**
165
+ * #8771 D7 — the thank-you's "Track this in your account" link, when the API
166
+ * answered `next: { completion: "invite" }` and this site can build one; else
167
+ * exactly `{ kind: "success" }`. The application is already written, so a
168
+ * refusal or a throw anywhere here is still a success — an error would invite
169
+ * a duplicate application.
170
+ */
171
+ async function successState(
172
+ next: unknown,
173
+ email: string,
174
+ ): Promise<LeadCaptureFormState> {
175
+ try {
176
+ const parsed = leadSubmitNextSchema.safeParse(next);
177
+ if (!parsed.success || parsed.data.completion !== "invite") {
178
+ return { kind: "success" };
179
+ }
180
+ const hint = accountEmailHintSchema.safeParse(email);
181
+ if (!hint.success) return { kind: "success" };
182
+ const locale = await getLocale();
183
+ const href = accountInviteHref(locale, hint.data);
184
+ if (href === null) return { kind: "success" };
185
+ const t = await getTranslations({ locale, namespace: "contact" });
186
+ return { kind: "success", next: { href, label: t("trackInAccount") } };
187
+ } catch {
188
+ return { kind: "success" };
189
+ }
190
+ }
191
+
192
+ export async function captureLeadAction(
193
+ job: string,
194
+ _prevState: LeadCaptureFormState,
195
+ formData: FormData,
196
+ ): Promise<LeadCaptureFormState> {
197
+ const honeypot = formData.get(LEAD_CAPTURE_HONEYPOT);
198
+ if (typeof honeypot === "string" && honeypot !== "")
199
+ return { kind: "success" };
200
+
201
+ // A bound argument comes back from the browser: check its type, not only its text.
202
+ if (typeof job !== "string" || !JOB_SLUG_RE.test(job)) {
203
+ return { kind: "error", message: GENERIC_ERROR };
204
+ }
205
+
206
+ const fieldErrors: Record<string, string> = {};
207
+ if (formData.get("consent") !== "true")
208
+ fieldErrors.consent = CONSENT_REQUIRED;
209
+
210
+ const parsed = applicationSchema.safeParse({
211
+ first_name: field(formData, "first_name") ?? "",
212
+ last_name: field(formData, "last_name") ?? "",
213
+ email: field(formData, "email") ?? "",
214
+ phone: field(formData, "phone"),
215
+ linkedin_url: field(formData, "linkedin_url"),
216
+ });
217
+ if (!parsed.success) {
218
+ for (const issue of parsed.error.issues) {
219
+ const name = String(issue.path[0] ?? "form");
220
+ fieldErrors[name] ??= issue.message;
221
+ }
222
+ }
223
+ if (!parsed.success || Object.keys(fieldErrors).length > 0) {
224
+ return { kind: "error", fieldErrors };
225
+ }
226
+
227
+ const extra = extraFields(formData);
228
+ if (extra === null) return { kind: "error", message: GENERIC_ERROR };
229
+
230
+ const { first_name, last_name, email, phone, linkedin_url } = parsed.data;
231
+ const payload = {
232
+ ...extra,
233
+ name: `${first_name} ${last_name}`,
234
+ first_name,
235
+ last_name,
236
+ ...(phone !== undefined && { phone }),
237
+ ...(linkedin_url !== undefined && { linkedin_url }),
238
+ job,
239
+ };
240
+
241
+ let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
242
+ try {
243
+ requestHeaders = await headers();
244
+ } catch {
245
+ // No request scope — no forwarding, and the visitor is "unknown".
246
+ }
247
+
248
+ const visitor = requestHeaders
249
+ ? clientIpFromHeaders(requestHeaders)
250
+ : "unknown";
251
+ if (
252
+ visitor !== "unknown" &&
253
+ !checkRateLimit(
254
+ `contact:${visitor}`,
255
+ RATE_LIMIT_MAX_CALLS,
256
+ RATE_LIMIT_WINDOW_MS,
257
+ )
258
+ ) {
259
+ return { kind: "error", message: RATE_LIMITED };
260
+ }
261
+
262
+ let init: { headers: Record<string, string> } | undefined;
263
+ if (requestHeaders) {
264
+ const forwarded = visitorForwardHeaders(
265
+ requestHeaders,
266
+ process.env.AILK_VISITOR_FORWARD_KEY,
267
+ );
268
+ if (forwarded) init = { headers: forwarded };
269
+ }
270
+ try {
271
+ const visitorId = readConsentVisitor(await cookies());
272
+ if (visitorId)
273
+ init = {
274
+ headers: {
275
+ ...(init?.headers ?? {}),
276
+ ...consentVisitorHeader(visitorId),
277
+ },
278
+ };
279
+ } catch {
280
+ // No request scope — submit with no visitor-link header.
281
+ }
282
+
283
+ const res = await writeLead(
284
+ { source: SOURCE, email, page: "careers", payload },
285
+ init,
286
+ );
287
+ if (!res.ok) {
288
+ if (isNotConfigured(res) || isApiUnreachable(res))
289
+ return { kind: "unavailable" };
290
+ return { kind: "error", message: GENERIC_ERROR };
291
+ }
292
+ return successState(res.body.next, email);
293
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * jobApplicationFormFromConfig — the Job application magnet's field step, as
3
+ * the shared `LeadCaptureForm` block renders it on a job page (#8746 D5).
4
+ *
5
+ * Reads the config's first step, which must be a `field` step holding fields
6
+ * named `first_name`, `last_name` and `email` (what the apply action needs);
7
+ * anything else answers `null` and the page renders the built-in form, the
8
+ * seeded config itself (`jobApplicationFormFallback`).
9
+ *
10
+ * Each `text`, `email`, `tel`, `url`, `textarea` or `select` field is mapped
11
+ * in order. First and last name sit side by side, then email and phone; every
12
+ * other field runs full width. `consent` and `address` fields are not
13
+ * rendered: the block draws its own terms box, labelled with the step's first
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.
16
+ *
17
+ * Copy resolves with the one LocalizedString rule every flow surface shares
18
+ * (`resolveLocalizedString`). Pure: no fetch, no React.
19
+ */
20
+ import type { ContactFormField } from "@working-theory/ui";
21
+ import {
22
+ jobApplicationFlowConfig,
23
+ resolveLocalizedString,
24
+ type FlowConfig,
25
+ } from "@working-theory/validation";
26
+
27
+ export interface JobApplicationFormView {
28
+ /** The step's `copy.title`. */
29
+ title: string;
30
+ fields: ContactFormField[];
31
+ /** The step's first consent field's label — `[Label](key)` tokens intact. */
32
+ consentStatement?: string;
33
+ /** The step's `copy.cta`. */
34
+ submitLabel?: string;
35
+ }
36
+
37
+ type FieldStep = Extract<FlowConfig["steps"][number], { type: "field" }>;
38
+ type FieldDef = FieldStep["fields"][number];
39
+
40
+ const RENDERED_TYPES = [
41
+ "text",
42
+ "email",
43
+ "tel",
44
+ "url",
45
+ "textarea",
46
+ "select",
47
+ ] as const;
48
+ type RenderedType = (typeof RENDERED_TYPES)[number];
49
+
50
+ const REQUIRED_NAMES = ["first_name", "last_name", "email"] as const;
51
+ const PAIRED_NAMES: ReadonlySet<string> = new Set([
52
+ "first_name",
53
+ "last_name",
54
+ "email",
55
+ "phone",
56
+ ]);
57
+
58
+ function isRendered(type: FieldDef["inputType"]): type is RenderedType {
59
+ return (RENDERED_TYPES as readonly string[]).includes(type);
60
+ }
61
+
62
+ export function jobApplicationFormFromConfig(
63
+ config: FlowConfig,
64
+ locale: string,
65
+ ): JobApplicationFormView | null {
66
+ const step = config.steps[0];
67
+ if (step?.type !== "field") return null;
68
+ const names = new Set(step.fields.map((f) => f.name));
69
+ if (!REQUIRED_NAMES.every((name) => names.has(name))) return null;
70
+
71
+ const resolve = (value: Record<string, string>): string =>
72
+ resolveLocalizedString(value, locale, config.defaultLocale);
73
+
74
+ const fields: ContactFormField[] = [];
75
+ for (const field of step.fields) {
76
+ if (!isRendered(field.inputType)) continue;
77
+ fields.push({
78
+ name: field.name,
79
+ type: field.inputType,
80
+ label: resolve(field.copy.label),
81
+ required: field.required ?? false,
82
+ ...(field.copy.placeholder !== undefined && {
83
+ placeholder: resolve(field.copy.placeholder),
84
+ }),
85
+ ...(field.copy.help !== undefined && {
86
+ helpText: resolve(field.copy.help),
87
+ }),
88
+ ...(field.options !== undefined && {
89
+ options: field.options.map((o) => ({
90
+ value: o.value,
91
+ label: resolve(o.label),
92
+ })),
93
+ }),
94
+ colSpan: PAIRED_NAMES.has(field.name) ? 1 : 2,
95
+ });
96
+ }
97
+
98
+ const consent = step.fields.find((f) => f.inputType === "consent");
99
+ return {
100
+ title: resolve(step.copy.title),
101
+ fields,
102
+ ...(consent !== undefined && {
103
+ consentStatement: resolve(consent.copy.label),
104
+ }),
105
+ ...(step.copy.cta !== undefined && { submitLabel: resolve(step.copy.cta) }),
106
+ };
107
+ }
108
+
109
+ /** The built-in form: the seeded magnet's own config. */
110
+ export function jobApplicationFormFallback(
111
+ locale: string,
112
+ ): JobApplicationFormView {
113
+ // The seeded config always maps — its test pins first_name, last_name, email.
114
+ return jobApplicationFormFromConfig(jobApplicationFlowConfig, locale)!;
115
+ }
@@ -1,16 +1,100 @@
1
1
  // JobPosting detail page. Reads the JobPosting MDX for the given slug, renders via
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
+ //
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";
4
21
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
5
22
  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";
6
34
  import type { Metadata } from "next";
35
+ import { getTranslations } from "next-intl/server";
7
36
  import type { ReactElement } from "react";
8
37
 
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
+
9
50
  interface Props {
10
51
  params: Promise<{ locale: string; slug: string }>;
11
52
  getMdxSource?: typeof defaultGetMdxSource;
12
53
  }
13
54
 
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
+
14
98
  export async function generateMetadata({
15
99
  params,
16
100
  }: Pick<Props, "params">): Promise<Metadata> {
@@ -23,11 +107,72 @@ export default async function Page({
23
107
  getMdxSource,
24
108
  }: Props): Promise<ReactElement> {
25
109
  const { locale, slug } = await params;
26
- return renderPage({
110
+ const rendered = await renderPage({
27
111
  type: "JobPosting",
28
112
  slug,
29
113
  locale,
30
114
  bodyMode: "mdx-body",
31
115
  ...(getMdxSource !== undefined && { getMdxSource }),
32
116
  });
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
+ );
33
178
  }