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,149 @@
1
+ /**
2
+ * The careers set end to end (#8849 D7, D8, D9, D10; AC-7): one `writer`
3
+ * posting, served by one shared content mock, through /careers, the posting
4
+ * page and the Free apply page. The `signal-collectors` walk over the same
5
+ * posting is apply-routes.test.tsx's (#8840 D11), which reads the moved
6
+ * `lib/posting-title.ts` through the same page-renderer loader.
7
+ */
8
+ import React from "react";
9
+ import { render, screen } from "@testing-library/react";
10
+ import * as pageRenderer from "@working-theory/page-renderer";
11
+ import { jobApplicationFlowConfig } from "@working-theory/validation";
12
+
13
+ jest.mock("next/navigation", () => ({
14
+ __esModule: true,
15
+ notFound: jest.fn(() => {
16
+ throw new Error("NEXT_NOT_FOUND");
17
+ }),
18
+ }));
19
+
20
+ jest.mock("next-mdx-remote/rsc", () => ({
21
+ __esModule: true,
22
+ MDXRemote: ({ source }: { source: string }) =>
23
+ React.createElement("div", { "data-testid": "mdx-rendered" }, source),
24
+ }));
25
+
26
+ jest.mock("@working-theory/api-client", () => ({
27
+ flows: { get: jest.fn() },
28
+ leads: { captureStatus: jest.fn(), submit: jest.fn() },
29
+ visitorForwardHeaders: () => undefined,
30
+ }));
31
+
32
+ jest.mock("next/headers", () => ({
33
+ __esModule: true,
34
+ headers: jest.fn().mockResolvedValue({ get: () => null }),
35
+ cookies: jest.fn().mockResolvedValue({ get: () => undefined }),
36
+ }));
37
+
38
+ // eslint-disable-next-line no-restricted-syntax -- test-seam mock: the real read is a node:fs frontmatter parse of content/_site.mdx; narrowest possible scope
39
+ jest.mock("../../contact/site-contact-email", () => ({
40
+ readSiteContactEmail: jest.fn().mockReturnValue("jobs@example.com"),
41
+ }));
42
+
43
+ import { flows, leads } from "@working-theory/api-client";
44
+
45
+ import CareersPage from "../page";
46
+ import JobPage from "../[slug]/page";
47
+ import ApplyPage from "../[slug]/[action]/page";
48
+
49
+ const WRITER = {
50
+ type: "JobPosting" as const,
51
+ slug: "writer",
52
+ title: "Writer",
53
+ description: "Write for us.",
54
+ employmentType: "FULL_TIME" as const,
55
+ remote: true,
56
+ location: "Remote (US)",
57
+ };
58
+
59
+ type GetMdxSource = typeof pageRenderer.getMdxSource;
60
+
61
+ const getMdxSource = jest.fn<ReturnType<GetMdxSource>, Parameters<GetMdxSource>>(async (type, slug) => {
62
+ if (type === "CareersList")
63
+ return {
64
+ frontmatter: {
65
+ type: "CareersList" as const,
66
+ slug: "careers",
67
+ title: "Careers",
68
+ description: "Open roles.",
69
+ },
70
+ body: "",
71
+ };
72
+ if (type === "JobPosting" && slug === "writer")
73
+ return { frontmatter: WRITER, body: "The writer role." };
74
+ return null;
75
+ });
76
+ const getMdxSlugs = jest.fn().mockResolvedValue(["writer"]);
77
+
78
+ beforeEach(() => {
79
+ // `lib/posting-title.ts` reads through the package loader, not a prop.
80
+ jest.spyOn(pageRenderer, "getMdxSource").mockImplementation(getMdxSource);
81
+ (flows.get as jest.Mock).mockResolvedValue({
82
+ ok: true,
83
+ status: 200,
84
+ body: jobApplicationFlowConfig,
85
+ });
86
+ (leads.captureStatus as jest.Mock).mockResolvedValue({
87
+ ok: true,
88
+ status: 200,
89
+ body: { ok: true, data: { contact: true, newsletter: true } },
90
+ });
91
+ });
92
+
93
+ afterEach(() => {
94
+ jest.restoreAllMocks();
95
+ });
96
+
97
+ describe("a writer posting renders through the careers set (#8849 AC-7)", () => {
98
+ it("/careers' generated card links to /careers/writer", async () => {
99
+ render(
100
+ await CareersPage({
101
+ params: Promise.resolve({ locale: "en" }),
102
+ getMdxSource,
103
+ getMdxSlugs,
104
+ }),
105
+ );
106
+ const links = Array.from(document.querySelectorAll("a")).map((a) =>
107
+ a.getAttribute("href"),
108
+ );
109
+ expect(links).toContain("/careers/writer");
110
+ expect(screen.getByTestId("items-filter-search")).toBeTruthy();
111
+ });
112
+
113
+ it("/careers/writer renders the posting, its Apply button, and an ApplyAction to the same page", async () => {
114
+ const { container } = render(
115
+ await JobPage({
116
+ params: Promise.resolve({ locale: "en", slug: "writer" }),
117
+ getMdxSource,
118
+ }),
119
+ );
120
+ expect(screen.getByRole("heading", { level: 1, name: "Writer" })).toBeTruthy();
121
+ const cta = screen.getByTestId("job-apply-cta");
122
+ expect(cta.getAttribute("href")).toBe("/careers/writer/apply");
123
+
124
+ const jsonLd = JSON.parse(
125
+ container.querySelector("[data-testid='json-ld']")!.textContent!,
126
+ ) as { "@graph": { "@type": string; mainEntity?: Record<string, unknown> }[] };
127
+ const posting = jsonLd["@graph"].find((node) => node["@type"] === "WebPage")!
128
+ .mainEntity!;
129
+ expect(posting["potentialAction"]).toEqual({
130
+ "@type": "ApplyAction",
131
+ target: "http://localhost:3000/en/careers/writer/apply",
132
+ });
133
+ expect(posting["employmentType"]).toBe("FULL_TIME");
134
+ expect(posting["jobLocationType"]).toBe("TELECOMMUTE");
135
+ });
136
+
137
+ it("the Free page at /careers/writer/apply renders the form bound to writer", async () => {
138
+ render(
139
+ await ApplyPage({
140
+ params: Promise.resolve({ locale: "en", slug: "writer", action: "apply" }),
141
+ }),
142
+ );
143
+ expect(screen.getByRole("heading", { level: 1, name: "Writer" })).toBeTruthy();
144
+ expect(screen.getByTestId("lead-capture-form")).toBeTruthy();
145
+ expect(screen.getByTestId("nav-back-link").getAttribute("href")).toBe(
146
+ "/careers/writer",
147
+ );
148
+ });
149
+ });
@@ -4,6 +4,7 @@
4
4
  * form that stands in for an absent or switched-off magnet.
5
5
  */
6
6
  import {
7
+ JOB_APPLICATION_TERMS_STATEMENT,
7
8
  jobApplicationFlowConfig,
8
9
  type FlowConfig,
9
10
  } from "@working-theory/validation";
@@ -18,7 +19,10 @@ const clone = (): FlowConfig =>
18
19
  JSON.parse(JSON.stringify(jobApplicationFlowConfig)) as FlowConfig;
19
20
 
20
21
  describe("jobApplicationFormFromConfig (#8746 D5, AC-4)", () => {
21
- it("maps the seeded config to First, Last, Email, Phone and LinkedIn URL, with the step's title and submit label", () => {
22
+ // #8840 D3 — the seed is two steps: step 1 (names, email, phone, the hidden
23
+ // `job`, the terms box) draws inline; LinkedIn URL moved to step 2, whose
24
+ // "Submit application" is now the submit label.
25
+ it("maps the seeded config's step 1 to First, Last, Email and Phone, with the step's title and step 2's submit label", () => {
22
26
  const view = jobApplicationFormFromConfig(jobApplicationFlowConfig, "en");
23
27
  expect(view?.title).toBe("Job application");
24
28
  expect(view?.fields).toEqual([
@@ -50,13 +54,6 @@ describe("jobApplicationFormFromConfig (#8746 D5, AC-4)", () => {
50
54
  required: false,
51
55
  colSpan: 1,
52
56
  },
53
- {
54
- name: "linkedin_url",
55
- type: "url",
56
- label: "LinkedIn URL",
57
- required: false,
58
- colSpan: 2,
59
- },
60
57
  ]);
61
58
  expect(view?.submitLabel).toBe("Submit application");
62
59
  });
@@ -67,7 +64,7 @@ describe("jobApplicationFormFromConfig (#8746 D5, AC-4)", () => {
67
64
  FlowConfig["steps"][number],
68
65
  { type: "field" }
69
66
  >;
70
- step.fields[4]!.copy = { label: { en: "Portfolio" } };
67
+ step.fields[3]!.copy = { label: { en: "Mobile" } };
71
68
  step.fields.push({
72
69
  name: "cover_letter",
73
70
  inputType: "textarea",
@@ -78,8 +75,7 @@ describe("jobApplicationFormFromConfig (#8746 D5, AC-4)", () => {
78
75
  ["first_name", "First name", 1],
79
76
  ["last_name", "Last name", 1],
80
77
  ["email", "Email", 1],
81
- ["phone", "Phone", 1],
82
- ["linkedin_url", "Portfolio", 2],
78
+ ["phone", "Mobile", 1],
83
79
  ["cover_letter", "Cover letter", 2],
84
80
  ]);
85
81
  });
@@ -111,26 +107,28 @@ describe("a consent field on the magnet (#8746 D5)", () => {
111
107
  FlowConfig["steps"][number],
112
108
  { type: "field" }
113
109
  >;
114
- step.fields.push({
115
- name: "retention",
116
- inputType: "consent",
117
- required: true,
118
- purposes: ["service_terms"],
119
- copy: {
120
- label: {
121
- en: "I agree my application is kept for 12 months under the [Privacy Policy](privacy).",
122
- },
110
+ const terms = step.fields.find((f) => f.name === "terms")!;
111
+ terms.copy = {
112
+ label: {
113
+ en: "I agree my application is kept for 12 months under the [Privacy Policy](privacy).",
123
114
  },
124
- });
115
+ };
125
116
  const view = jobApplicationFormFromConfig(config, "en");
126
117
  expect(view?.consentStatement).toBe(
127
118
  "I agree my application is kept for 12 months under the [Privacy Policy](privacy).",
128
119
  );
129
- expect(view?.fields.map((f) => f.name)).not.toContain("retention");
120
+ expect(view?.fields.map((f) => f.name)).not.toContain("terms");
121
+ });
122
+
123
+ // #8840 D3 — the seed now carries a terms box, so the page labels its own.
124
+ it("the seeded config's terms field labels the box", () => {
125
+ expect(jobApplicationFormFallback("en").consentStatement).toBe(
126
+ JOB_APPLICATION_TERMS_STATEMENT,
127
+ );
130
128
  });
131
129
 
132
- it("the seeded config carries none, so the page keeps the catalog's statement", () => {
133
- expect(jobApplicationFormFallback("en").consentStatement).toBeUndefined();
130
+ it("never draws the hidden job field", () => {
131
+ expect(jobApplicationFormFallback("en").fields.map((f) => f.name)).not.toContain("job");
134
132
  });
135
133
  });
136
134
 
@@ -1,13 +1,14 @@
1
1
  /**
2
- * careers/[slug]/page.tsx and careers/page.tsx over the Job application
3
- * magnet (#8746 D1, D5; AC-4, AC-5).
2
+ * The careers set over the Job application magnet (#8746 D1, D5; #8849 D7,
3
+ * D8; AC-5, AC-6): the posting page carries an Apply button and no form, and
4
+ * the Free apply page, careers/[slug]/[action]/page.tsx, draws the form.
4
5
  *
5
6
  * Renders the REAL `LeadCaptureForm` bound to the REAL apply action, so the
6
- * `#apply` section, its fields and a submit are all visible. Mocked
7
- * boundaries: `@working-theory/api-client` (`flows.get`,
8
- * `leads.captureStatus`, `leads.submit`), `next/headers`, `next-intl/server`
9
- * (over the real English catalog), the `_site.mdx` contact-email read, and the
10
- * page-renderer's content loader (injected).
7
+ * form, its fields and a submit are all visible. Mocked boundaries:
8
+ * `@working-theory/api-client` (`flows.get`, `leads.captureStatus`,
9
+ * `leads.submit`), `next/headers`, `next-intl/server` (over the real English
10
+ * catalog), the `_site.mdx` contact-email read, and the page-renderer's
11
+ * content loader (injected, and spied on for `lib/posting-title.ts`'s read).
11
12
  */
12
13
  import React from "react";
13
14
  import {
@@ -69,34 +70,40 @@ jest.mock("../../contact/site-contact-email", () => ({
69
70
  readSiteContactEmail: jest.fn().mockReturnValue("jobs@example.com"),
70
71
  }));
71
72
 
73
+ import { readFileSync } from "node:fs";
74
+ import { join } from "node:path";
75
+
72
76
  import { flows, leads } from "@working-theory/api-client";
77
+ import * as pageRenderer from "@working-theory/page-renderer";
73
78
 
74
79
  import messages from "../../../../messages/en.json";
75
80
 
76
81
  import CareersPage from "../page";
77
82
  import JobPage from "../[slug]/page";
83
+ import * as jobPageModule from "../[slug]/page";
84
+ import ApplyPage, { generateMetadata as applyMetadata } from "../[slug]/[action]/page";
78
85
 
79
86
  const mockFlowsGet = flows.get as jest.Mock;
80
87
  const mockCaptureStatus = leads.captureStatus as jest.Mock;
81
88
  const mockSubmit = leads.submit as jest.Mock;
89
+ let mockPostingSource: jest.SpyInstance;
82
90
 
83
91
  type FieldStep = Extract<FlowConfig["steps"][number], { type: "field" }>;
84
92
 
85
- const JOB = "content-strategist";
93
+ const JOB = "writer";
86
94
  const jobSource = {
87
95
  frontmatter: {
88
96
  type: "JobPosting" as const,
89
97
  slug: JOB,
90
- title: "Content Strategist",
91
- description: "Own the content pipeline.",
98
+ title: "Writer",
99
+ description: "Write for us.",
92
100
  },
93
101
  body: "mdx-body-content",
94
102
  };
95
103
 
96
- async function renderJobPage() {
97
- const element = await JobPage({
98
- params: Promise.resolve({ locale: "en", slug: JOB }),
99
- getMdxSource: jest.fn().mockResolvedValue(jobSource),
104
+ async function renderApplyPage(slug = JOB, action = "apply") {
105
+ const element = await ApplyPage({
106
+ params: Promise.resolve({ locale: "en", slug, action }),
100
107
  });
101
108
  return render(element);
102
109
  }
@@ -112,9 +119,9 @@ function fieldNames(): string[] {
112
119
  .filter((name) => !name.includes("-"));
113
120
  }
114
121
 
115
- // The job page shares its two hub reads for a few seconds, per module instance
116
- // (as /contact does, #8731). Each test starts an hour later so none sees
117
- // another's read.
122
+ // The apply page shares its two hub reads for a few seconds, per module
123
+ // instance (as /contact does, #8731). Each test starts an hour later so none
124
+ // sees another's read.
118
125
  let clock = 1_800_000_000_000;
119
126
  const nowSpy = jest.spyOn(Date, "now");
120
127
 
@@ -132,50 +139,86 @@ beforeEach(() => {
132
139
  body: jobApplicationFlowConfig,
133
140
  });
134
141
  mockSubmit.mockResolvedValue({ ok: true, status: 201, body: { ok: true } });
142
+ // `lib/posting-title.ts` reads the posting through the package loader.
143
+ mockPostingSource = jest
144
+ .spyOn(pageRenderer, "getMdxSource")
145
+ .mockImplementation(async (_type, slug) => (slug === JOB ? jobSource : null));
135
146
  });
136
147
 
137
148
  afterEach(() => {
138
149
  jest.clearAllMocks();
150
+ mockPostingSource.mockRestore();
139
151
  });
140
152
 
141
- describe("a job page renders the Job application under #apply (#8746 D1, D5, AC-4)", () => {
142
- it("reads the job-application magnet and draws its field step inside the #apply section", async () => {
143
- await renderJobPage();
144
- expect(mockFlowsGet).toHaveBeenCalledWith("job-application");
145
- const section = document.getElementById("apply");
146
- expect(section).not.toBeNull();
147
- expect(section!.contains(screen.getByTestId("lead-capture-form"))).toBe(
148
- true,
153
+ describe("the posting page carries an Apply button and no form (#8849 D8, AC-5)", () => {
154
+ it("renders job-apply-cta to the localized apply page, and no form or hub read", async () => {
155
+ const element = await JobPage({
156
+ params: Promise.resolve({ locale: "fr", slug: JOB }),
157
+ getMdxSource: jest.fn().mockResolvedValue(jobSource),
158
+ });
159
+ render(element);
160
+ const cta = screen.getByTestId("job-apply-cta");
161
+ expect(cta.getAttribute("href")).toBe("/fr/careers/writer/apply");
162
+ expect(cta.textContent).toBe(messages.careers.apply);
163
+ expect(screen.queryByTestId("job-apply")).toBeNull();
164
+ expect(document.getElementById("apply")).toBeNull();
165
+ expect(screen.queryByTestId("lead-capture-form")).toBeNull();
166
+ expect(document.querySelector("form")).toBeNull();
167
+ expect(mockFlowsGet).not.toHaveBeenCalled();
168
+ expect(mockCaptureStatus).not.toHaveBeenCalled();
169
+ // The posting itself still renders above it.
170
+ expect(screen.getByTestId("mdx-rendered").textContent).toBe(
171
+ "mdx-body-content",
149
172
  );
173
+ });
174
+
175
+ it("an unknown posting is a 404 and builds no button", async () => {
176
+ await expect(
177
+ JobPage({
178
+ params: Promise.resolve({ locale: "en", slug: "unknown" }),
179
+ getMdxSource: jest.fn().mockResolvedValue(null),
180
+ }),
181
+ ).rejects.toThrow("NEXT_NOT_FOUND");
182
+ expect(screen.queryByTestId("job-apply-cta")).toBeNull();
183
+ expect(mockFlowsGet).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it("the page module no longer exports dynamic", () => {
187
+ expect("dynamic" in jobPageModule).toBe(false);
188
+ });
189
+ });
190
+
191
+ describe("the Free apply page draws the Job application (#8849 D7, AC-6; #8746 D1, D5)", () => {
192
+ it("renders a back link to the posting, the posting's title and the magnet's field step", async () => {
193
+ await renderApplyPage();
194
+ expect(mockFlowsGet).toHaveBeenCalledWith("job-application");
150
195
  expect(
151
- screen.getByRole("heading", { level: 2, name: "Job application" }),
196
+ screen.getByRole("heading", { level: 1, name: "Writer" }),
152
197
  ).toBeTruthy();
198
+ expect(screen.getByTestId("nav-back-link").getAttribute("href")).toBe(
199
+ "/careers/writer",
200
+ );
153
201
  expect(fieldNames()).toEqual([
154
202
  "first_name",
155
203
  "last_name",
156
204
  "email",
157
205
  "phone",
158
- "linkedin_url",
159
206
  ]);
160
207
  expect(screen.getByTestId("contact-submit").textContent).toBe(
161
208
  "Submit application",
162
209
  );
163
- // The posting itself still renders above it.
164
- expect(screen.getByTestId("mdx-rendered").textContent).toBe(
165
- "mdx-body-content",
166
- );
167
210
  });
168
211
 
169
212
  it("an owner's edit to the magnet shows on the page", async () => {
170
213
  const edited = JSON.parse(
171
214
  JSON.stringify(jobApplicationFlowConfig),
172
215
  ) as FlowConfig;
173
- (edited.steps[0] as FieldStep).fields[4]!.copy.label = { en: "Portfolio" };
216
+ (edited.steps[0] as FieldStep).fields[2]!.copy.label = { en: "Work email" };
174
217
  mockFlowsGet.mockResolvedValue({ ok: true, status: 200, body: edited });
175
- await renderJobPage();
218
+ await renderApplyPage();
176
219
  expect(
177
- screen.getByTestId("field-linkedin_url").closest("div")!.textContent,
178
- ).toContain("Portfolio");
220
+ screen.getByTestId("field-email").closest("div")!.textContent,
221
+ ).toContain("Work email");
179
222
  });
180
223
 
181
224
  it.each([
@@ -203,14 +246,12 @@ describe("a job page renders the Job application under #apply (#8746 D1, D5, AC-
203
246
  "falls back to the built-in form with the same fields when the magnet is %s",
204
247
  async (_label, arrange) => {
205
248
  arrange();
206
- await renderJobPage();
207
- expect(document.getElementById("apply")).not.toBeNull();
249
+ await renderApplyPage();
208
250
  expect(fieldNames()).toEqual([
209
251
  "first_name",
210
252
  "last_name",
211
253
  "email",
212
254
  "phone",
213
- "linkedin_url",
214
255
  ]);
215
256
  },
216
257
  );
@@ -221,30 +262,65 @@ describe("a job page renders the Job application under #apply (#8746 D1, D5, AC-
221
262
  status: 200,
222
263
  body: { ok: true, data: { contact: false, newsletter: false } },
223
264
  });
224
- await renderJobPage();
225
- expect(document.getElementById("apply")).not.toBeNull();
265
+ await renderApplyPage();
226
266
  expect(screen.queryByTestId("lead-capture-form")).toBeNull();
227
- expect(document.getElementById("apply")!.textContent).toContain(
267
+ expect(screen.getByTestId("job-apply").textContent).toContain(
228
268
  "jobs@example.com",
229
269
  );
230
270
  });
231
271
 
232
- it("an unknown posting is a 404 and reads no magnet", async () => {
233
- await expect(
234
- JobPage({
235
- params: Promise.resolve({ locale: "en", slug: "unknown" }),
236
- getMdxSource: jest.fn().mockResolvedValue(null),
237
- }),
238
- ).rejects.toThrow("NEXT_NOT_FOUND");
272
+ it.each([
273
+ ["an action other than apply", JOB, "start"],
274
+ ["an uppercase slug", "Writer", "apply"],
275
+ ["an encoded traversal slug", "a%2F..%2Fb", "apply"],
276
+ ])("%s is a 404 before any read", async (_label, slug, action) => {
277
+ await expect(renderApplyPage(slug, action)).rejects.toThrow("NEXT_NOT_FOUND");
278
+ expect(mockPostingSource).not.toHaveBeenCalled();
279
+ expect(mockFlowsGet).not.toHaveBeenCalled();
280
+ expect(mockCaptureStatus).not.toHaveBeenCalled();
281
+ });
282
+
283
+ it("an unknown posting is a 404 with no hub read", async () => {
284
+ await expect(renderApplyPage("unknown")).rejects.toThrow("NEXT_NOT_FOUND");
285
+ expect(mockPostingSource).toHaveBeenCalledWith("JobPosting", "unknown", "en");
239
286
  expect(mockFlowsGet).not.toHaveBeenCalled();
287
+ expect(mockCaptureStatus).not.toHaveBeenCalled();
288
+ });
289
+
290
+ it("is noindex, follow, titled with the posting", async () => {
291
+ const metadata = await applyMetadata({
292
+ params: Promise.resolve({ locale: "en", slug: JOB, action: "apply" }),
293
+ });
294
+ expect(metadata.robots).toEqual({ index: false, follow: true });
295
+ expect(metadata.title).toBe("Writer");
296
+ const unknown = await applyMetadata({
297
+ params: Promise.resolve({ locale: "en", slug: "unknown", action: "apply" }),
298
+ });
299
+ expect(unknown.robots).toEqual({ index: false, follow: true });
300
+ });
301
+
302
+ it("is core: it imports no flows/ or module file, and reads the title from lib/posting-title", () => {
303
+ const source = readFileSync(
304
+ join(__dirname, "..", "[slug]", "[action]", "page.tsx"),
305
+ "utf8",
306
+ );
307
+ const imports = source
308
+ .split("\n")
309
+ .filter((line) => /^import |^\} from |^ [\w{}, ]+ from /.test(line) || line.includes(" from \""))
310
+ .join("\n");
311
+ expect(imports).not.toMatch(/flows\//);
312
+ expect(imports).not.toContain("FlowStepper");
313
+ expect(imports).not.toContain("@working-theory/ui-signal-collectors");
314
+ expect(imports).not.toMatch(/["']\.\/apply|\/apply\//);
315
+ expect(imports).toContain('from "../../../../../lib/posting-title"');
240
316
  });
241
317
  });
242
318
 
243
- describe("the job page shares its two hub reads (#8746 security review, as #8731)", () => {
319
+ describe("the Free apply page shares its two hub reads (#8746 security review, as #8731)", () => {
244
320
  it("a burst of views makes one hub read of each kind, not one per view", async () => {
245
321
  for (let i = 0; i < 25; i += 1) {
246
- const view = await renderJobPage();
247
- expect(document.getElementById("apply")).not.toBeNull();
322
+ const view = await renderApplyPage();
323
+ expect(screen.getByTestId("lead-capture-form")).toBeTruthy();
248
324
  view.unmount();
249
325
  }
250
326
  expect(mockFlowsGet).toHaveBeenCalledTimes(1);
@@ -255,13 +331,12 @@ describe("the job page shares its two hub reads (#8746 security review, as #8731
255
331
  mockFlowsGet.mockRejectedValue(new Error("hub down"));
256
332
  mockCaptureStatus.mockRejectedValue(new Error("hub down"));
257
333
  for (let i = 0; i < 5; i += 1) {
258
- const view = await renderJobPage();
334
+ const view = await renderApplyPage();
259
335
  expect(fieldNames()).toEqual([
260
336
  "first_name",
261
337
  "last_name",
262
338
  "email",
263
339
  "phone",
264
- "linkedin_url",
265
340
  ]);
266
341
  view.unmount();
267
342
  }
@@ -270,34 +345,33 @@ describe("the job page shares its two hub reads (#8746 security review, as #8731
270
345
  });
271
346
 
272
347
  it("an Admin edit shows once the shared read expires", async () => {
273
- const first = await renderJobPage();
348
+ const first = await renderApplyPage();
274
349
  expect(
275
- screen.getByTestId("field-linkedin_url").closest("div")!.textContent,
276
- ).toContain("LinkedIn URL");
350
+ screen.getByTestId("field-email").closest("div")!.textContent,
351
+ ).toContain("Email");
277
352
  first.unmount();
278
353
 
279
354
  const edited = JSON.parse(
280
355
  JSON.stringify(jobApplicationFlowConfig),
281
356
  ) as FlowConfig;
282
- (edited.steps[0] as FieldStep).fields[4]!.copy.label = { en: "Portfolio" };
357
+ (edited.steps[0] as FieldStep).fields[2]!.copy.label = { en: "Work email" };
283
358
  mockFlowsGet.mockResolvedValue({ ok: true, status: 200, body: edited });
284
359
  clock += 60_000;
285
- await renderJobPage();
360
+ await renderApplyPage();
286
361
  expect(
287
- screen.getByTestId("field-linkedin_url").closest("div")!.textContent,
288
- ).toContain("Portfolio");
362
+ screen.getByTestId("field-email").closest("div")!.textContent,
363
+ ).toContain("Work email");
289
364
  expect(mockFlowsGet).toHaveBeenCalledTimes(2);
290
365
  });
291
366
  });
292
367
 
293
- describe("a submission from the job page lands as a job application lead (#8746 D4, D5, AC-5)", () => {
368
+ describe("a submission from the Free apply page lands as a job application lead (#8746 D4, D5; #8849 AC-6)", () => {
294
369
  it("posts source flow:job-application, name = first + last and the posting's slug as job", async () => {
295
- await renderJobPage();
370
+ await renderApplyPage();
296
371
  for (const [name, value] of Object.entries({
297
372
  first_name: "Ada",
298
373
  last_name: "Lovelace",
299
374
  email: "ada@example.com",
300
- linkedin_url: "https://www.linkedin.com/in/ada",
301
375
  })) {
302
376
  fireEvent.change(screen.getByTestId(`field-${name}`), {
303
377
  target: { value },
@@ -318,7 +392,6 @@ describe("a submission from the job page lands as a job application lead (#8746
318
392
  expect(body.payload).toMatchObject({
319
393
  name: "Ada Lovelace",
320
394
  job: JOB,
321
- linkedin_url: "https://www.linkedin.com/in/ada",
322
395
  page: "careers",
323
396
  });
324
397
  // #8746 — the careers catalog's own success line, not /contact's.
@@ -328,10 +401,11 @@ describe("a submission from the job page lands as a job application lead (#8746
328
401
  });
329
402
  });
330
403
 
331
- describe("the /careers index links to each job and carries no form (#8746 D1, AC-4)", () => {
404
+ describe("the /careers index links to each job and carries no form (#8746 D1, #8849 D10)", () => {
332
405
  it("renders the list with no apply form and reads no magnet", async () => {
333
406
  const element = await CareersPage({
334
407
  params: Promise.resolve({ locale: "en" }),
408
+ getMdxSlugs: jest.fn().mockResolvedValue([]),
335
409
  getMdxSource: jest.fn().mockResolvedValue({
336
410
  frontmatter: {
337
411
  type: "CareersList",
@@ -339,7 +413,7 @@ describe("the /careers index links to each job and carries no form (#8746 D1, AC
339
413
  title: "Careers",
340
414
  description: "Open roles.",
341
415
  },
342
- body: "- [Content Strategist](/careers/content-strategist)",
416
+ body: "",
343
417
  }),
344
418
  });
345
419
  render(element);