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.
- package/component-catalog.json +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +11 -9
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +294 -12
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +7 -5
- package/dist/derive-architecture.js +9 -32
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +0 -4
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +3 -2
- package/dist/lib/scaffold-render-sweep.js +5 -8
- package/dist/module-architecture.js +98 -10
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/spoke-checkout.d.ts +38 -35
- package/dist/spoke-checkout.js +66 -229
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +33 -12
- package/dist/sync-routes.d.ts +38 -1
- package/dist/sync-routes.js +184 -11
- package/dist/template-record.d.ts +1 -1
- package/dist/template-record.js +3 -2
- package/dist/update.js +10 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +9 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +27 -0
- package/templates/apps/api/CLAUDE.md +77 -16
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/stripe.ts +74 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/spec.ts +10 -7
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +4 -2
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/index.ts +17 -14
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +91 -17
- package/templates/apps/api/src/server.ts +140 -51
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
- package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +113 -58
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +124 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- package/templates/apps/mcp/CLAUDE.md +22 -14
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +10 -31
- package/templates/apps/web/CLAUDE.md +315 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +6 -2
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +9 -20
- package/templates/apps/web/lib/deck-page.ts +1 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +42 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +202 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
- package/templates/apps/web/messages/en.json +49 -15
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +31 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +31 -1
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +30 -2
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
- package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/checkout.ts +0 -93
- 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
|
-
...(
|
|
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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
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
|
+
});
|