create-ailk 0.21.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 +186 -8
- package/component-catalog.md +9 -7
- package/dist/add-module.js +20 -14
- 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 +12 -3
- package/dist/derive-architecture.js +20 -8
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +5 -5
- 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 +99 -9
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/programmatic.js +19 -5
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/spoke-checkout.d.ts +65 -41
- package/dist/spoke-checkout.js +208 -430
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +52 -26
- package/dist/sync-routes.d.ts +38 -1
- package/dist/sync-routes.js +187 -13
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +8 -4
- package/dist/topology.js +25 -1
- package/dist/update.js +15 -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/designs/minimalism/DESIGN.md +1 -1
- 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 +87 -17
- 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__/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/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- 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/lib/stripe.ts +74 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- 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/auth.ts +13 -6
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/spec.ts +33 -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/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 +386 -0
- 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 +268 -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/__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/get-page.ts +4 -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/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +164 -7
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -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 +245 -36
- package/templates/apps/api/src/server.ts +163 -46
- 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 +1383 -133
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- 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/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -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 +431 -47
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- 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 +164 -17
- package/templates/apps/api/src/services/plan-offerings.ts +125 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +27 -15
- 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 +24 -31
- package/templates/apps/web/CLAUDE.md +393 -9
- 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]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +295 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +120 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +23 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +319 -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 +141 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +424 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +72 -11
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- 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 +17 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +160 -13
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +466 -36
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +172 -57
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -60
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- 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 +341 -4
- 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__/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]/__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]/account-invite.actions.ts +35 -0
- 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]/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 +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]/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]/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]/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]/team/__tests__/team-routes.test.tsx +4 -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]/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/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- 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/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/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/auth.ts +28 -2
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +7 -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 +101 -25
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +16 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +7 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -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/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/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +449 -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 +71 -20
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/security-headers.config.ts +17 -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/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 +69 -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__/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 +64 -1
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/inbox/schema.prisma +17 -2
- 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/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 +62 -3
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
- 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/lib/__tests__/meeting-slugs.test.ts +0 -120
- 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
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
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`. 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.
|
|
27
|
+
*
|
|
28
|
+
* Checks, before any write: the honeypot (a filled one answers success and
|
|
29
|
+
* sends nothing); first name, last name and email required; a LinkedIn URL,
|
|
30
|
+
* when given, an http(s) URL — a bare domain gains `https://`, the flow
|
|
31
|
+
* engine's `url` idiom; the terms box ticked, as the form block requires; and
|
|
32
|
+
* the per-visitor limit /contact runs, in /contact's own bucket so a visitor's
|
|
33
|
+
* lead posts stay at 30 per 10 minutes however they are split (skipped when
|
|
34
|
+
* no client address is readable). Outcomes map as /contact's do: the API's
|
|
35
|
+
* `lead_capture_not_configured` or an unreachable API is `unavailable`, any
|
|
36
|
+
* other refusal the generic error.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { visitorForwardHeaders } from "@working-theory/api-client";
|
|
40
|
+
import {
|
|
41
|
+
LEAD_CAPTURE_HONEYPOT,
|
|
42
|
+
type LeadCaptureFormState,
|
|
43
|
+
} from "@working-theory/ui";
|
|
44
|
+
import {
|
|
45
|
+
accountEmailHintSchema,
|
|
46
|
+
flowLeadSource,
|
|
47
|
+
JOB_APPLICATION_FLOW_SLUG,
|
|
48
|
+
leadSubmitNextSchema,
|
|
49
|
+
} from "@working-theory/validation";
|
|
50
|
+
import { cookies, headers } from "next/headers";
|
|
51
|
+
import { getLocale, getTranslations } from "next-intl/server";
|
|
52
|
+
import { z } from "zod";
|
|
53
|
+
|
|
54
|
+
import { accountInviteHref } from "../../../../lib/account-invite";
|
|
55
|
+
import {
|
|
56
|
+
consentVisitorHeader,
|
|
57
|
+
readConsentVisitor,
|
|
58
|
+
} from "../../../../lib/consent-visitor";
|
|
59
|
+
import { writeLead } from "../../../../src/lib/lead-write";
|
|
60
|
+
import {
|
|
61
|
+
checkRateLimit,
|
|
62
|
+
clientIpFromHeaders,
|
|
63
|
+
} from "../../../../src/lib/rate-limit";
|
|
64
|
+
|
|
65
|
+
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
66
|
+
const RATE_LIMITED =
|
|
67
|
+
"Too many applications from here. Please try again in a few minutes.";
|
|
68
|
+
const CONSENT_REQUIRED = "Please accept the terms to send your application.";
|
|
69
|
+
|
|
70
|
+
const RATE_LIMIT_MAX_CALLS = 30;
|
|
71
|
+
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
|
|
72
|
+
|
|
73
|
+
const SOURCE = flowLeadSource(JOB_APPLICATION_FLOW_SLUG);
|
|
74
|
+
const JOB_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
|
75
|
+
const HAS_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
76
|
+
|
|
77
|
+
/** Field names the action reads itself, or writes — never copied from the form. */
|
|
78
|
+
const RESERVED_FIELDS = new Set([
|
|
79
|
+
"first_name",
|
|
80
|
+
"last_name",
|
|
81
|
+
"email",
|
|
82
|
+
"phone",
|
|
83
|
+
"linkedin_url",
|
|
84
|
+
"consent",
|
|
85
|
+
"name",
|
|
86
|
+
"job",
|
|
87
|
+
"source",
|
|
88
|
+
LEAD_CAPTURE_HONEYPOT,
|
|
89
|
+
]);
|
|
90
|
+
const MAX_EXTRA_FIELDS = 20;
|
|
91
|
+
const MAX_EXTRA_FIELD_LENGTH = 2000;
|
|
92
|
+
|
|
93
|
+
const optionalText = (max: number) =>
|
|
94
|
+
z.preprocess(
|
|
95
|
+
(v) => (typeof v === "string" && v.trim() !== "" ? v.trim() : undefined),
|
|
96
|
+
z.string().max(max).optional(),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/** A LinkedIn URL: blank is absent; a bare domain gains `https://`; only http(s). */
|
|
100
|
+
const linkedinUrl = z.preprocess(
|
|
101
|
+
(v) => {
|
|
102
|
+
if (typeof v !== "string" || v.trim() === "") return undefined;
|
|
103
|
+
const value = v.trim();
|
|
104
|
+
return HAS_SCHEME_RE.test(value) ? value : `https://${value}`;
|
|
105
|
+
},
|
|
106
|
+
z
|
|
107
|
+
.string()
|
|
108
|
+
.max(2000)
|
|
109
|
+
.refine((value) => {
|
|
110
|
+
try {
|
|
111
|
+
const url = new URL(value);
|
|
112
|
+
return (
|
|
113
|
+
(url.protocol === "https:" || url.protocol === "http:") &&
|
|
114
|
+
url.hostname.includes(".") &&
|
|
115
|
+
// No credentials: `https://linkedin.com@evil.example/` would show
|
|
116
|
+
// the operator a LinkedIn-looking link to another host.
|
|
117
|
+
url.username === "" &&
|
|
118
|
+
url.password === ""
|
|
119
|
+
);
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}, "Enter a valid URL.")
|
|
124
|
+
.optional(),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const applicationSchema = z.object({
|
|
128
|
+
first_name: z.string().trim().min(1, "Enter your first name.").max(100),
|
|
129
|
+
last_name: z.string().trim().min(1, "Enter your last name.").max(100),
|
|
130
|
+
email: z.string().trim().email("Enter a valid email address.").max(254),
|
|
131
|
+
phone: optionalText(32),
|
|
132
|
+
linkedin_url: linkedinUrl,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** The extra fields, or `null` over the bounds. Files and `$ACTION_…` keys are skipped. */
|
|
136
|
+
function extraFields(formData: FormData): Record<string, string> | null {
|
|
137
|
+
const extra: Record<string, string> = {};
|
|
138
|
+
let count = 0;
|
|
139
|
+
for (const [key, value] of formData.entries()) {
|
|
140
|
+
if (RESERVED_FIELDS.has(key) || key.startsWith("$ACTION")) continue;
|
|
141
|
+
if (typeof value !== "string") continue;
|
|
142
|
+
if (value.length > MAX_EXTRA_FIELD_LENGTH) return null;
|
|
143
|
+
if (!Object.hasOwn(extra, key)) count += 1;
|
|
144
|
+
if (count > MAX_EXTRA_FIELDS) return null;
|
|
145
|
+
extra[key] = value;
|
|
146
|
+
}
|
|
147
|
+
return extra;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isNotConfigured(res: { status: number; body: unknown }): boolean {
|
|
151
|
+
return (
|
|
152
|
+
res.status === 503 &&
|
|
153
|
+
typeof res.body === "object" &&
|
|
154
|
+
res.body !== null &&
|
|
155
|
+
(res.body as { error?: unknown }).error === "lead_capture_not_configured"
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isApiUnreachable(res: { status: number }): boolean {
|
|
160
|
+
return res.status === 0 || res.status === 502 || res.status === 504;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const field = (formData: FormData, name: string): unknown =>
|
|
164
|
+
formData.get(name) ?? undefined;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* #8771 D7 — the thank-you's "Track this in your account" link, when the API
|
|
168
|
+
* answered `next: { completion: "invite" }` and this site can build one; else
|
|
169
|
+
* exactly `{ kind: "success" }`. The application is already written, so a
|
|
170
|
+
* refusal or a throw anywhere here is still a success — an error would invite
|
|
171
|
+
* a duplicate application.
|
|
172
|
+
*/
|
|
173
|
+
async function successState(
|
|
174
|
+
next: unknown,
|
|
175
|
+
email: string,
|
|
176
|
+
): Promise<LeadCaptureFormState> {
|
|
177
|
+
try {
|
|
178
|
+
const parsed = leadSubmitNextSchema.safeParse(next);
|
|
179
|
+
if (!parsed.success || parsed.data.completion !== "invite") {
|
|
180
|
+
return { kind: "success" };
|
|
181
|
+
}
|
|
182
|
+
const hint = accountEmailHintSchema.safeParse(email);
|
|
183
|
+
if (!hint.success) return { kind: "success" };
|
|
184
|
+
const locale = await getLocale();
|
|
185
|
+
const href = accountInviteHref(locale, hint.data);
|
|
186
|
+
if (href === null) return { kind: "success" };
|
|
187
|
+
const t = await getTranslations({ locale, namespace: "contact" });
|
|
188
|
+
return { kind: "success", next: { href, label: t("trackInAccount") } };
|
|
189
|
+
} catch {
|
|
190
|
+
return { kind: "success" };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function captureLeadAction(
|
|
195
|
+
job: string,
|
|
196
|
+
_prevState: LeadCaptureFormState,
|
|
197
|
+
formData: FormData,
|
|
198
|
+
): Promise<LeadCaptureFormState> {
|
|
199
|
+
const honeypot = formData.get(LEAD_CAPTURE_HONEYPOT);
|
|
200
|
+
if (typeof honeypot === "string" && honeypot !== "")
|
|
201
|
+
return { kind: "success" };
|
|
202
|
+
|
|
203
|
+
// A bound argument comes back from the browser: check its type, not only its text.
|
|
204
|
+
if (typeof job !== "string" || !JOB_SLUG_RE.test(job)) {
|
|
205
|
+
return { kind: "error", message: GENERIC_ERROR };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const fieldErrors: Record<string, string> = {};
|
|
209
|
+
if (formData.get("consent") !== "true")
|
|
210
|
+
fieldErrors.consent = CONSENT_REQUIRED;
|
|
211
|
+
|
|
212
|
+
const parsed = applicationSchema.safeParse({
|
|
213
|
+
first_name: field(formData, "first_name") ?? "",
|
|
214
|
+
last_name: field(formData, "last_name") ?? "",
|
|
215
|
+
email: field(formData, "email") ?? "",
|
|
216
|
+
phone: field(formData, "phone"),
|
|
217
|
+
linkedin_url: field(formData, "linkedin_url"),
|
|
218
|
+
});
|
|
219
|
+
if (!parsed.success) {
|
|
220
|
+
for (const issue of parsed.error.issues) {
|
|
221
|
+
const name = String(issue.path[0] ?? "form");
|
|
222
|
+
fieldErrors[name] ??= issue.message;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (!parsed.success || Object.keys(fieldErrors).length > 0) {
|
|
226
|
+
return { kind: "error", fieldErrors };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const extra = extraFields(formData);
|
|
230
|
+
if (extra === null) return { kind: "error", message: GENERIC_ERROR };
|
|
231
|
+
|
|
232
|
+
const { first_name, last_name, email, phone, linkedin_url } = parsed.data;
|
|
233
|
+
const payload = {
|
|
234
|
+
...extra,
|
|
235
|
+
name: `${first_name} ${last_name}`,
|
|
236
|
+
first_name,
|
|
237
|
+
last_name,
|
|
238
|
+
...(phone !== undefined && { phone }),
|
|
239
|
+
...(linkedin_url !== undefined && { linkedin_url }),
|
|
240
|
+
job,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
|
|
244
|
+
try {
|
|
245
|
+
requestHeaders = await headers();
|
|
246
|
+
} catch {
|
|
247
|
+
// No request scope — no forwarding, and the visitor is "unknown".
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const visitor = requestHeaders
|
|
251
|
+
? clientIpFromHeaders(requestHeaders)
|
|
252
|
+
: "unknown";
|
|
253
|
+
if (
|
|
254
|
+
visitor !== "unknown" &&
|
|
255
|
+
!checkRateLimit(
|
|
256
|
+
`contact:${visitor}`,
|
|
257
|
+
RATE_LIMIT_MAX_CALLS,
|
|
258
|
+
RATE_LIMIT_WINDOW_MS,
|
|
259
|
+
)
|
|
260
|
+
) {
|
|
261
|
+
return { kind: "error", message: RATE_LIMITED };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let init: { headers: Record<string, string> } | undefined;
|
|
265
|
+
if (requestHeaders) {
|
|
266
|
+
const forwarded = visitorForwardHeaders(
|
|
267
|
+
requestHeaders,
|
|
268
|
+
process.env.AILK_VISITOR_FORWARD_KEY,
|
|
269
|
+
);
|
|
270
|
+
if (forwarded) init = { headers: forwarded };
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const visitorId = readConsentVisitor(await cookies());
|
|
274
|
+
if (visitorId)
|
|
275
|
+
init = {
|
|
276
|
+
headers: {
|
|
277
|
+
...(init?.headers ?? {}),
|
|
278
|
+
...consentVisitorHeader(visitorId),
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
} catch {
|
|
282
|
+
// No request scope — submit with no visitor-link header.
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const res = await writeLead(
|
|
286
|
+
{ source: SOURCE, email, page: "careers", consent: true, payload },
|
|
287
|
+
init,
|
|
288
|
+
);
|
|
289
|
+
if (!res.ok) {
|
|
290
|
+
if (isNotConfigured(res) || isApiUnreachable(res))
|
|
291
|
+
return { kind: "unavailable" };
|
|
292
|
+
return { kind: "error", message: GENERIC_ERROR };
|
|
293
|
+
}
|
|
294
|
+
return successState(res.body.next, email);
|
|
295
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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 — 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).
|
|
18
|
+
*
|
|
19
|
+
* Copy resolves with the one LocalizedString rule every flow surface shares
|
|
20
|
+
* (`resolveLocalizedString`). Pure: no fetch, no React.
|
|
21
|
+
*/
|
|
22
|
+
import type { ContactFormField } from "@working-theory/ui";
|
|
23
|
+
import {
|
|
24
|
+
jobApplicationFlowConfig,
|
|
25
|
+
resolveLocalizedString,
|
|
26
|
+
type FlowConfig,
|
|
27
|
+
} from "@working-theory/validation";
|
|
28
|
+
|
|
29
|
+
export interface JobApplicationFormView {
|
|
30
|
+
/** The step's `copy.title`. */
|
|
31
|
+
title: string;
|
|
32
|
+
fields: ContactFormField[];
|
|
33
|
+
/** The step's first consent field's label — `[Label](key)` tokens intact. */
|
|
34
|
+
consentStatement?: string;
|
|
35
|
+
/** The step's `copy.cta`, else the last field step's (#8840 D3). */
|
|
36
|
+
submitLabel?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type FieldStep = Extract<FlowConfig["steps"][number], { type: "field" }>;
|
|
40
|
+
type FieldDef = FieldStep["fields"][number];
|
|
41
|
+
|
|
42
|
+
const RENDERED_TYPES = [
|
|
43
|
+
"text",
|
|
44
|
+
"email",
|
|
45
|
+
"tel",
|
|
46
|
+
"url",
|
|
47
|
+
"textarea",
|
|
48
|
+
"select",
|
|
49
|
+
] as const;
|
|
50
|
+
type RenderedType = (typeof RENDERED_TYPES)[number];
|
|
51
|
+
|
|
52
|
+
const REQUIRED_NAMES = ["first_name", "last_name", "email"] as const;
|
|
53
|
+
const PAIRED_NAMES: ReadonlySet<string> = new Set([
|
|
54
|
+
"first_name",
|
|
55
|
+
"last_name",
|
|
56
|
+
"email",
|
|
57
|
+
"phone",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
function isRendered(type: FieldDef["inputType"]): type is RenderedType {
|
|
61
|
+
return (RENDERED_TYPES as readonly string[]).includes(type);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function jobApplicationFormFromConfig(
|
|
65
|
+
config: FlowConfig,
|
|
66
|
+
locale: string,
|
|
67
|
+
): JobApplicationFormView | null {
|
|
68
|
+
const step = config.steps[0];
|
|
69
|
+
if (step?.type !== "field") return null;
|
|
70
|
+
const names = new Set(step.fields.map((f) => f.name));
|
|
71
|
+
if (!REQUIRED_NAMES.every((name) => names.has(name))) return null;
|
|
72
|
+
|
|
73
|
+
const resolve = (value: Record<string, string>): string =>
|
|
74
|
+
resolveLocalizedString(value, locale, config.defaultLocale);
|
|
75
|
+
|
|
76
|
+
const fields: ContactFormField[] = [];
|
|
77
|
+
for (const field of step.fields) {
|
|
78
|
+
if (!isRendered(field.inputType)) continue;
|
|
79
|
+
fields.push({
|
|
80
|
+
name: field.name,
|
|
81
|
+
type: field.inputType,
|
|
82
|
+
label: resolve(field.copy.label),
|
|
83
|
+
required: field.required ?? false,
|
|
84
|
+
...(field.copy.placeholder !== undefined && {
|
|
85
|
+
placeholder: resolve(field.copy.placeholder),
|
|
86
|
+
}),
|
|
87
|
+
...(field.copy.help !== undefined && {
|
|
88
|
+
helpText: resolve(field.copy.help),
|
|
89
|
+
}),
|
|
90
|
+
...(field.options !== undefined && {
|
|
91
|
+
options: field.options.map((o) => ({
|
|
92
|
+
value: o.value,
|
|
93
|
+
label: resolve(o.label),
|
|
94
|
+
})),
|
|
95
|
+
}),
|
|
96
|
+
colSpan: PAIRED_NAMES.has(field.name) ? 1 : 2,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
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;
|
|
104
|
+
return {
|
|
105
|
+
title: resolve(step.copy.title),
|
|
106
|
+
fields,
|
|
107
|
+
...(consent !== undefined && {
|
|
108
|
+
consentStatement: resolve(consent.copy.label),
|
|
109
|
+
}),
|
|
110
|
+
...(cta !== undefined && { submitLabel: resolve(cta) }),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The built-in form: the seeded magnet's own config. */
|
|
115
|
+
export function jobApplicationFormFallback(
|
|
116
|
+
locale: string,
|
|
117
|
+
): JobApplicationFormView {
|
|
118
|
+
// The seeded config always maps — its test pins first_name, last_name, email.
|
|
119
|
+
return jobApplicationFormFromConfig(jobApplicationFlowConfig, locale)!;
|
|
120
|
+
}
|
|
@@ -1,9 +1,18 @@
|
|
|
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, 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.
|
|
4
11
|
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
5
12
|
import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
|
|
13
|
+
import { Button, Container, Link, localeHref } from "@working-theory/ui";
|
|
6
14
|
import type { Metadata } from "next";
|
|
15
|
+
import { getTranslations } from "next-intl/server";
|
|
7
16
|
import type { ReactElement } from "react";
|
|
8
17
|
|
|
9
18
|
interface Props {
|
|
@@ -23,11 +32,25 @@ export default async function Page({
|
|
|
23
32
|
getMdxSource,
|
|
24
33
|
}: Props): Promise<ReactElement> {
|
|
25
34
|
const { locale, slug } = await params;
|
|
35
|
+
const t = await getTranslations({ locale, namespace: "careers" });
|
|
26
36
|
return renderPage({
|
|
27
37
|
type: "JobPosting",
|
|
28
38
|
slug,
|
|
29
39
|
locale,
|
|
30
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
|
+
),
|
|
31
54
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
32
55
|
});
|
|
33
56
|
}
|