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.
- package/README.md +21 -1
- package/component-catalog.json +220 -8
- package/component-catalog.md +21 -9
- package/dist/add-module.js +9 -5
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +63 -25
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +30 -14
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +124 -1
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.d.ts +6 -1
- package/dist/update.js +125 -18
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/README.md +26 -25
- package/templates/apps/api/CLAUDE.md +20 -7
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/openapi/spec.ts +30 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/account.ts +266 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +161 -7
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/lead-routing.ts +337 -8
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/person.ts +40 -8
- package/templates/apps/api/src/services/plan-offerings.ts +59 -0
- package/templates/apps/api/src/services/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +139 -3
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/checkout-config.ts +27 -0
- package/templates/apps/web/lib/checkout.ts +93 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +192 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +40 -12
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/database/CHANGELOG.md +47 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/seed.test.ts +72 -2
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/jest.config.cjs +1 -0
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +60 -0
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +34 -34
- 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
|
-
|
|
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
|
}
|