create-ailk 0.22.0 → 0.24.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 +27 -14
- package/component-catalog.json +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +17 -10
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +301 -13
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +7 -5
- package/dist/derive-architecture.js +26 -32
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +0 -4
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +36 -2
- package/dist/lib/scaffold-render-sweep.js +50 -8
- package/dist/module-architecture.js +115 -10
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/spoke-checkout.d.ts +38 -35
- package/dist/spoke-checkout.js +66 -229
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +33 -12
- package/dist/sync-routes.d.ts +68 -5
- package/dist/sync-routes.js +455 -29
- package/dist/template-record.d.ts +1 -1
- package/dist/template-record.js +3 -2
- package/dist/update.js +10 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +13 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +34 -0
- package/templates/apps/api/CLAUDE.md +94 -19
- 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 +7 -1
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +77 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +343 -7
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -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 +1426 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +4 -2
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- 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/search.ts +16 -45
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/index.ts +377 -308
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -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 +6 -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 +93 -17
- package/templates/apps/api/src/server.ts +165 -51
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
- package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +113 -58
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -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 +975 -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 +400 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1083 -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 +147 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- package/templates/apps/mcp/CLAUDE.md +32 -18
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- 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 +95 -14
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- 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__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- 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/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +10 -31
- package/templates/apps/web/CLAUDE.md +324 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +9 -20
- package/templates/apps/web/lib/deck-page.ts +1 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +44 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +205 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
- package/templates/apps/web/messages/en.json +49 -15
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +51 -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 +63 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +57 -2
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/api/src/routes/content/slug.ts +0 -9
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
- package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/checkout.ts +0 -93
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* in-page-magnet.ts — the in-page form shape (#8847 D6, D15).
|
|
3
|
+
*
|
|
4
|
+
* A contact, quote or newsletter magnet renders from its hub record inside a
|
|
5
|
+
* chrome-on page: the page holds layout only, and every visible fact on the
|
|
6
|
+
* form (heading, sentence, fields, consent, button, ending) comes from the
|
|
7
|
+
* magnet's served config. /contact, /newsletter, /unsubscribe and the
|
|
8
|
+
* footer signup all read through here.
|
|
9
|
+
*
|
|
10
|
+
* - `sharedHubRead` shares a hub read across requests for HUB_READ_TTL_MS
|
|
11
|
+
* (#8731, moved here from /contact): a visitor view carries no forwarding
|
|
12
|
+
* headers, so on a spoke every view would otherwise count against the
|
|
13
|
+
* spoke server's one per-IP limit at the hub. An Admin edit shows within
|
|
14
|
+
* that window.
|
|
15
|
+
* - `readServedMagnet(slug)` is the public flow read; any throw or non-2xx
|
|
16
|
+
* (absent, draft, a Free build with no flow route) is `null`.
|
|
17
|
+
* - `inPageView(config, locale, form)` maps a config's one field step. The
|
|
18
|
+
* form kind comes from the CALLER, never the config or the slug (C1): a
|
|
19
|
+
* served config carries no `kind`.
|
|
20
|
+
* - `framedSources` makes the page's first `contact-form` section carry the
|
|
21
|
+
* magnet's title and body, and — when that section leads the page — the
|
|
22
|
+
* frontmatter title too, so `renderPage`'s title carve-out gives the
|
|
23
|
+
* section the `<h1>` and `renderPageMetadata` gives `<title>` the same text.
|
|
24
|
+
* - `renderInPageMagnet` is the slotted surface for a slug (D15): the form
|
|
25
|
+
* bound to its action, or `ContactFallback` when capture is off.
|
|
26
|
+
*
|
|
27
|
+
* Pure apart from the two reads; no React state.
|
|
28
|
+
*/
|
|
29
|
+
import { flows, leads } from "@working-theory/api-client";
|
|
30
|
+
import type {
|
|
31
|
+
getMdxSource as defaultGetMdxSource,
|
|
32
|
+
getPageSections as defaultGetPageSections,
|
|
33
|
+
} from "@working-theory/page-renderer";
|
|
34
|
+
import {
|
|
35
|
+
ContactFallback,
|
|
36
|
+
LeadCaptureForm,
|
|
37
|
+
type LeadCaptureFormText,
|
|
38
|
+
localeHref,
|
|
39
|
+
NewsletterFormInline,
|
|
40
|
+
} from "@working-theory/ui";
|
|
41
|
+
import {
|
|
42
|
+
flowLeadSource,
|
|
43
|
+
flowSitePathSchema,
|
|
44
|
+
resolveLocalizedString,
|
|
45
|
+
type FlowConfig,
|
|
46
|
+
} from "@working-theory/validation";
|
|
47
|
+
import { createElement, type ReactElement } from "react";
|
|
48
|
+
|
|
49
|
+
import {
|
|
50
|
+
contactFormFromConfig,
|
|
51
|
+
type ContactFormView,
|
|
52
|
+
} from "../app/[locale]/contact/contact-form-magnet";
|
|
53
|
+
import { captureLeadAction } from "../app/[locale]/contact/contact.actions";
|
|
54
|
+
|
|
55
|
+
import { consentPoliciesFor } from "./consent-policies";
|
|
56
|
+
import { subscribeNewsletterAction } from "./newsletter.actions";
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
export { fillEndingName } from "@working-theory/validation";
|
|
60
|
+
|
|
61
|
+
// ─── Shared hub reads (#8731) ──────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How long a hub read is shared across requests (#8731). Short, so an Admin
|
|
65
|
+
* edit to the magnet shows within seconds; long enough that a burst of views
|
|
66
|
+
* makes one read of each, not one per view.
|
|
67
|
+
*/
|
|
68
|
+
export const HUB_READ_TTL_MS = 15_000;
|
|
69
|
+
|
|
70
|
+
const hubReads = new Map<string, { at: number; read: Promise<unknown> }>();
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How long a page waits on one hub read before treating it as failed. The
|
|
74
|
+
* root layout reads the newsletter magnet for the footer on every page, so a
|
|
75
|
+
* hub that accepts the connection and stalls must not hold the site.
|
|
76
|
+
*/
|
|
77
|
+
export const HUB_READ_DEADLINE_MS = 2_000;
|
|
78
|
+
|
|
79
|
+
/** `read`'s result, or `fallback` once HUB_READ_DEADLINE_MS has passed. */
|
|
80
|
+
function withDeadline<T>(read: Promise<T>, fallback: T): Promise<T> {
|
|
81
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
82
|
+
const late = new Promise<T>((resolve) => {
|
|
83
|
+
timer = setTimeout(() => resolve(fallback), HUB_READ_DEADLINE_MS);
|
|
84
|
+
});
|
|
85
|
+
return Promise.race([read, late]).finally(() => clearTimeout(timer));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `read()`'s result, shared per instance for HUB_READ_TTL_MS. Concurrent calls
|
|
90
|
+
* share one in-flight read. A read that settles to `null` (any failed read,
|
|
91
|
+
* see the readers below) is shared the same way, so a burst against an
|
|
92
|
+
* unreachable hub is not a burst of retries.
|
|
93
|
+
*/
|
|
94
|
+
export function sharedHubRead<T>(key: string, read: () => Promise<T>): Promise<T> {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
const hit = hubReads.get(key);
|
|
97
|
+
if (hit && now - hit.at < HUB_READ_TTL_MS) return hit.read as Promise<T>;
|
|
98
|
+
const fresh = read();
|
|
99
|
+
hubReads.set(key, { at: now, read: fresh });
|
|
100
|
+
return fresh;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type CaptureStatus = Extract<
|
|
104
|
+
Awaited<ReturnType<typeof leads.captureStatus>>,
|
|
105
|
+
{ ok: true }
|
|
106
|
+
>["body"]["data"];
|
|
107
|
+
|
|
108
|
+
/** The API's capture status, or `null` for any failed read. */
|
|
109
|
+
export function readCaptureStatus(): Promise<CaptureStatus | null> {
|
|
110
|
+
return sharedHubRead("capture-status", () =>
|
|
111
|
+
withDeadline(
|
|
112
|
+
(async () => {
|
|
113
|
+
try {
|
|
114
|
+
const res = await leads.captureStatus();
|
|
115
|
+
return res.ok && res.body.ok === true ? res.body.data : null;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
})(),
|
|
120
|
+
null,
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The served magnet's config at `slug`, or `null` for any failed read: a
|
|
127
|
+
* non-2xx (absent, draft, a build with no flow route), a throw, or no answer
|
|
128
|
+
* within HUB_READ_DEADLINE_MS. The
|
|
129
|
+
* property access sits inside the `try`, so a client with no `flows` is a
|
|
130
|
+
* failed read too.
|
|
131
|
+
*/
|
|
132
|
+
export function readServedMagnet(slug: string): Promise<FlowConfig | null> {
|
|
133
|
+
return sharedHubRead(`magnet:${slug}`, () =>
|
|
134
|
+
withDeadline(
|
|
135
|
+
(async () => {
|
|
136
|
+
try {
|
|
137
|
+
const res = await flows.get(slug);
|
|
138
|
+
return res.ok ? res.body : null;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
})(),
|
|
143
|
+
null,
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── The view ──────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/** Which form a caller asks for (C1). */
|
|
151
|
+
export type InPageForm = "contact" | "newsletter";
|
|
152
|
+
|
|
153
|
+
/** The magnet's `ending.default`, resolved and localized (#8841 D1 shape). */
|
|
154
|
+
export type InPageEnding =
|
|
155
|
+
| {
|
|
156
|
+
kind: "screen";
|
|
157
|
+
headline: string;
|
|
158
|
+
body?: string;
|
|
159
|
+
ctas?: { label: string; href: string }[];
|
|
160
|
+
}
|
|
161
|
+
| { kind: "redirect"; href: string };
|
|
162
|
+
|
|
163
|
+
/** A newsletter magnet's one step, as `NewsletterFormInline` renders it. */
|
|
164
|
+
export interface NewsletterFormView {
|
|
165
|
+
kind: "newsletter";
|
|
166
|
+
/** The `email` field's name — the key the step submit sends the address under. */
|
|
167
|
+
emailField: string;
|
|
168
|
+
label: string;
|
|
169
|
+
placeholder?: string;
|
|
170
|
+
/** The step's first consent field: its name, purposes, statement and help line. */
|
|
171
|
+
consent?: { field: string; purposes: string[]; statement: string; help?: string };
|
|
172
|
+
submitLabel?: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface InPageView {
|
|
176
|
+
stepKey: string;
|
|
177
|
+
title: string;
|
|
178
|
+
body?: string;
|
|
179
|
+
/** Absent when the config has no `ending` and the caller gave no fallback line. */
|
|
180
|
+
ending?: InPageEnding;
|
|
181
|
+
form: ContactFormView | NewsletterFormView;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
type FieldStep = Extract<FlowConfig["steps"][number], { type: "field" }>;
|
|
185
|
+
|
|
186
|
+
/** The field types a visitor types or picks a value into. */
|
|
187
|
+
const ENTRY_TYPES = new Set(["text", "email", "tel", "url", "textarea", "select", "address"]);
|
|
188
|
+
|
|
189
|
+
function newsletterForm(
|
|
190
|
+
step: FieldStep,
|
|
191
|
+
resolve: (value: Record<string, string>) => string,
|
|
192
|
+
): NewsletterFormView | null {
|
|
193
|
+
const email = step.fields.find((f) => f.inputType === "email" && f.mapsTo === "email");
|
|
194
|
+
if (email === undefined) return null;
|
|
195
|
+
// Any other field a visitor fills in is not a one-field signup.
|
|
196
|
+
if (step.fields.some((f) => f !== email && ENTRY_TYPES.has(f.inputType))) return null;
|
|
197
|
+
const consent = step.fields.find((f) => f.inputType === "consent");
|
|
198
|
+
return {
|
|
199
|
+
kind: "newsletter",
|
|
200
|
+
emailField: email.name,
|
|
201
|
+
label: resolve(email.copy.label),
|
|
202
|
+
...(email.copy.placeholder !== undefined && { placeholder: resolve(email.copy.placeholder) }),
|
|
203
|
+
...(consent !== undefined && {
|
|
204
|
+
consent: {
|
|
205
|
+
field: consent.name,
|
|
206
|
+
purposes: consent.purposes ?? [],
|
|
207
|
+
statement: resolve(consent.copy.label),
|
|
208
|
+
...(consent.copy.help !== undefined && { help: resolve(consent.copy.help) }),
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
...(step.copy.cta !== undefined && { submitLabel: resolve(step.copy.cta) }),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A config's one field step as an in-page form of the kind the caller names,
|
|
217
|
+
* or `null` when it is not one: its first step is not a `field` step, a
|
|
218
|
+
* newsletter config has more than one step or a field other than its email
|
|
219
|
+
* and consent, or a contact config lacks `name` and `email`. A config with no
|
|
220
|
+
* `ending` ends on `fallbackHeadline` (the page's catalog success line), or
|
|
221
|
+
* has no `ending` when the caller gives none, so the form keeps its own.
|
|
222
|
+
*/
|
|
223
|
+
export function inPageView(
|
|
224
|
+
config: FlowConfig,
|
|
225
|
+
locale: string,
|
|
226
|
+
form: InPageForm,
|
|
227
|
+
fallbackHeadline?: string,
|
|
228
|
+
): InPageView | null {
|
|
229
|
+
const step = config.steps[0];
|
|
230
|
+
if (step?.type !== "field") return null;
|
|
231
|
+
const resolve = (value: Record<string, string>): string =>
|
|
232
|
+
resolveLocalizedString(value, locale, config.defaultLocale);
|
|
233
|
+
|
|
234
|
+
let view: ContactFormView | NewsletterFormView | null;
|
|
235
|
+
if (form === "newsletter") {
|
|
236
|
+
view = config.steps.length === 1 ? newsletterForm(step, resolve) : null;
|
|
237
|
+
} else {
|
|
238
|
+
view = contactFormFromConfig(config, locale);
|
|
239
|
+
}
|
|
240
|
+
if (view === null) return null;
|
|
241
|
+
|
|
242
|
+
// Every destination is re-checked against the site-path grammar before it
|
|
243
|
+
// is rendered or navigated to, as the walk does (#8840): a stored row that
|
|
244
|
+
// bypassed the write-side check never becomes an off-site link.
|
|
245
|
+
const onSite = (href: string): boolean => flowSitePathSchema.safeParse(href).success;
|
|
246
|
+
const outcome = config.ending?.default;
|
|
247
|
+
const fallback: InPageEnding | undefined =
|
|
248
|
+
fallbackHeadline === undefined ? undefined : { kind: "screen", headline: fallbackHeadline };
|
|
249
|
+
let ending: InPageEnding | undefined;
|
|
250
|
+
if (outcome === undefined) {
|
|
251
|
+
ending = fallback;
|
|
252
|
+
} else if (outcome.kind === "redirect") {
|
|
253
|
+
ending = onSite(outcome.href) ? { kind: "redirect", href: localeHref(locale, outcome.href) } : fallback;
|
|
254
|
+
} else {
|
|
255
|
+
const ctas = (outcome.ctas ?? []).filter((cta) => onSite(cta.href));
|
|
256
|
+
ending = {
|
|
257
|
+
kind: "screen",
|
|
258
|
+
headline: resolve(outcome.headline),
|
|
259
|
+
...(outcome.body !== undefined && { body: resolve(outcome.body) }),
|
|
260
|
+
...(ctas.length > 0 && {
|
|
261
|
+
ctas: ctas.map((cta) => ({
|
|
262
|
+
label: resolve(cta.label),
|
|
263
|
+
href: localeHref(locale, cta.href),
|
|
264
|
+
})),
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
stepKey: step.key,
|
|
271
|
+
title: resolve(step.copy.title),
|
|
272
|
+
...(step.copy.body !== undefined && { body: resolve(step.copy.body) }),
|
|
273
|
+
...(ending !== undefined && { ending }),
|
|
274
|
+
form: view,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─── The page frame ────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
type Loaders = {
|
|
281
|
+
getMdxSource: typeof defaultGetMdxSource;
|
|
282
|
+
getPageSections: typeof defaultGetPageSections;
|
|
283
|
+
};
|
|
284
|
+
type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The page's first `contact-form` section takes `headline = view.title` and
|
|
288
|
+
* `description = view.body` (deleted when there is none). When that section
|
|
289
|
+
* is the first non-alert section, the frontmatter `title` is `view.title`
|
|
290
|
+
* too. With no `contact-form` section, both loaders pass through unchanged.
|
|
291
|
+
*/
|
|
292
|
+
export function framedSources({
|
|
293
|
+
getMdxSource,
|
|
294
|
+
getPageSections,
|
|
295
|
+
view,
|
|
296
|
+
}: Loaders & { view: Pick<InPageView, "title" | "body"> }): Loaders {
|
|
297
|
+
const framedSections = async (frontmatter: unknown): Promise<Section[]> => {
|
|
298
|
+
const sections = await getPageSections(frontmatter);
|
|
299
|
+
const at = sections.findIndex((s) => s.type === "contact-form");
|
|
300
|
+
if (at < 0) return sections;
|
|
301
|
+
return sections.map((section, i) => {
|
|
302
|
+
if (i !== at || section.type !== "contact-form") return section;
|
|
303
|
+
const data = { ...section.data, headline: view.title };
|
|
304
|
+
if (view.body !== undefined) data.description = view.body;
|
|
305
|
+
else delete data.description;
|
|
306
|
+
return { ...section, data };
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
const framedSource: typeof defaultGetMdxSource = async (type, slug, locale) => {
|
|
310
|
+
const source = await getMdxSource(type, slug, locale);
|
|
311
|
+
if (source === null) return source;
|
|
312
|
+
const sections = await getPageSections(source.frontmatter);
|
|
313
|
+
const leading = sections.find((s) => s.type !== "alert");
|
|
314
|
+
if (leading?.type !== "contact-form") return source;
|
|
315
|
+
return { ...source, frontmatter: { ...source.frontmatter, title: view.title } };
|
|
316
|
+
};
|
|
317
|
+
return { getMdxSource: framedSource, getPageSections: framedSections };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── The slotted surface (D15) ─────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
export interface NewsletterSignupOptions {
|
|
323
|
+
locale: string;
|
|
324
|
+
fallbackEmail?: string;
|
|
325
|
+
/** The footer's compact form: stacked, the label `sr-only`, the placeholder naming the field. */
|
|
326
|
+
compact?: boolean;
|
|
327
|
+
className?: string;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The newsletter magnet's signup: the standard `NewsletterFormInline` bound to
|
|
332
|
+
* `subscribeNewsletterAction`, every word from the view. On /newsletter the
|
|
333
|
+
* email label is visible and the field and Subscribe share a row from `md`;
|
|
334
|
+
* the footer's compact form stacks, with the label `sr-only`.
|
|
335
|
+
*/
|
|
336
|
+
export function newsletterSignup(
|
|
337
|
+
view: InPageView & { form: NewsletterFormView },
|
|
338
|
+
{ locale, fallbackEmail, compact = false, className }: NewsletterSignupOptions,
|
|
339
|
+
): ReactElement {
|
|
340
|
+
const { form, ending } = view;
|
|
341
|
+
const placeholder = form.placeholder ?? (compact ? form.label : undefined);
|
|
342
|
+
return createElement(NewsletterFormInline, {
|
|
343
|
+
formAction: subscribeNewsletterAction,
|
|
344
|
+
emailLabel: form.label,
|
|
345
|
+
...(compact ? { layout: "stacked" as const } : { label: form.label }),
|
|
346
|
+
...(placeholder !== undefined && { placeholder }),
|
|
347
|
+
...(form.consent !== undefined && {
|
|
348
|
+
consentStatement: form.consent.statement,
|
|
349
|
+
policies: consentPoliciesFor(locale),
|
|
350
|
+
...(form.consent.help !== undefined && { helpText: form.consent.help }),
|
|
351
|
+
}),
|
|
352
|
+
...(form.submitLabel !== undefined && { submitLabel: form.submitLabel }),
|
|
353
|
+
// A redirect ending has no in-place form; the block's own success line stands.
|
|
354
|
+
...(ending?.kind === "screen" && {
|
|
355
|
+
ending: { headline: ending.headline, ...(ending.body !== undefined && { body: ending.body }) },
|
|
356
|
+
}),
|
|
357
|
+
...(fallbackEmail !== undefined && { fallbackEmail }),
|
|
358
|
+
...(className !== undefined && { className }),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export interface RenderInPageMagnetInput {
|
|
363
|
+
slug: string;
|
|
364
|
+
locale: string;
|
|
365
|
+
/** The page's view of the magnet (`inPageView`), mapped for the caller's form. */
|
|
366
|
+
view: InPageView;
|
|
367
|
+
/** The `contact` catalog's form copy (`contactFormText`). */
|
|
368
|
+
text: LeadCaptureFormText;
|
|
369
|
+
accepting: boolean;
|
|
370
|
+
fallbackEmail?: string;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The surface a page slots for an in-page magnet: the form bound to its
|
|
375
|
+
* action, or `ContactFallback` when capture is off. A `contact` form posts
|
|
376
|
+
* through `captureLeadAction` stamped `flowLeadSource(slug)`; the action keeps
|
|
377
|
+
* only the contact sources (`CONTACT_LEAD_SOURCES`), so a quote magnet's post
|
|
378
|
+
* lands as `contact-form` until that list admits its slug. A `newsletter`
|
|
379
|
+
* form posts through `subscribeNewsletterAction`.
|
|
380
|
+
*/
|
|
381
|
+
export function renderInPageMagnet({
|
|
382
|
+
slug,
|
|
383
|
+
locale,
|
|
384
|
+
view,
|
|
385
|
+
text,
|
|
386
|
+
accepting,
|
|
387
|
+
fallbackEmail,
|
|
388
|
+
}: RenderInPageMagnetInput): ReactElement {
|
|
389
|
+
if (!accepting) {
|
|
390
|
+
return createElement(ContactFallback, {
|
|
391
|
+
email: fallbackEmail,
|
|
392
|
+
text: {
|
|
393
|
+
heading: text.unavailableHeading,
|
|
394
|
+
body: text.unavailableBody,
|
|
395
|
+
noEmail: text.unavailableNoEmail,
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
const form = view.form;
|
|
400
|
+
if ("kind" in form) {
|
|
401
|
+
return newsletterSignup({ ...view, form }, { locale, ...(fallbackEmail !== undefined && { fallbackEmail }) });
|
|
402
|
+
}
|
|
403
|
+
return createElement(LeadCaptureForm, {
|
|
404
|
+
action: captureLeadAction,
|
|
405
|
+
text: form.consentStatement ? { ...text, consentStatement: form.consentStatement } : text,
|
|
406
|
+
termsHref: localeHref(locale, "/terms"),
|
|
407
|
+
policies: consentPoliciesFor(locale),
|
|
408
|
+
fallbackEmail,
|
|
409
|
+
fields: form.fields,
|
|
410
|
+
submitLabel: form.submitLabel,
|
|
411
|
+
ending: view.ending,
|
|
412
|
+
source: flowLeadSource(slug),
|
|
413
|
+
look: "column",
|
|
414
|
+
consentGate: "on-submit",
|
|
415
|
+
});
|
|
416
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The magnet walk's path predicate (#8840 D10, spec
|
|
3
|
+
* docs/specs/8840-a-default-full-screen-magnet-spec.md).
|
|
4
|
+
*
|
|
5
|
+
* A magnet's walk renders full-screen: `app/[locale]/layout.tsx` drops the
|
|
6
|
+
* Nav and the Footer for it, as it does for the auth and account paths, and
|
|
7
|
+
* keeps the cookie consent banner (a visitor can arrive straight at a walk).
|
|
8
|
+
* A walk is `/<magnet>/start` — any path whose last segment is `start` — or
|
|
9
|
+
* one of the two careers apply walks, `/careers/apply` and
|
|
10
|
+
* `/careers/<posting>/apply`, each bare or under an enabled-locale prefix
|
|
11
|
+
* (the `auth-routes.ts` rule). #8843's meeting pages add their paths here
|
|
12
|
+
* rather than writing a second predicate. #8848 D9 adds every page under
|
|
13
|
+
* `/reports/<slug>` — a gated landing, an ungated reader and the grant
|
|
14
|
+
* reader alike, since the path cannot tell them apart; `/reports` itself
|
|
15
|
+
* keeps its chrome. #9287 DD10 adds `/trial/request`, the trial magnet's
|
|
16
|
+
* walk; `/trial`, its landing, keeps its chrome.
|
|
17
|
+
*
|
|
18
|
+
* Fail-safe direction, as `isAuthPath()`: an absent or empty pathname
|
|
19
|
+
* answers `false`, so a missing `x-pathname` never strips a page's chrome.
|
|
20
|
+
*
|
|
21
|
+
* Core, not a module slice: the layout every scaffold carries imports it, and
|
|
22
|
+
* it names no paid code. No Next.js import — a pure function.
|
|
23
|
+
*/
|
|
24
|
+
export function isMagnetWalkPath(
|
|
25
|
+
pathname: string | undefined | null,
|
|
26
|
+
locales: readonly string[],
|
|
27
|
+
): boolean {
|
|
28
|
+
if (!pathname) return false;
|
|
29
|
+
const path = pathname.split(/[?#]/)[0] ?? "";
|
|
30
|
+
const bare =
|
|
31
|
+
locales.length > 0
|
|
32
|
+
? path.replace(new RegExp(`^/(${locales.join("|")})(?=/|$)`), "")
|
|
33
|
+
: path;
|
|
34
|
+
const segments = bare.split("/").filter((segment) => segment.length > 0);
|
|
35
|
+
if (segments.length === 0) return false;
|
|
36
|
+
if (segments[segments.length - 1] === "start") return true;
|
|
37
|
+
if (segments[0] === "reports" && segments.length === 2) return true;
|
|
38
|
+
if (segments.length === 2 && segments[0] === "trial" && segments[1] === "request") return true;
|
|
39
|
+
return (
|
|
40
|
+
segments[0] === "careers" &&
|
|
41
|
+
segments[segments.length - 1] === "apply" &&
|
|
42
|
+
(segments.length === 2 || segments.length === 3)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// modules.generated.ts — which module pages this site carries.
|
|
2
|
+
//
|
|
3
|
+
// GENERATED by create-ailk from this app's package.json dependencies (#8843).
|
|
4
|
+
// Do not edit: every scaffold, `create-ailk add-module` and
|
|
5
|
+
// `create-ailk update` rewrites it. It imports nothing, so edge code may
|
|
6
|
+
// read it as well as the locale layout.
|
|
7
|
+
|
|
8
|
+
/** The scheduling module's booking pages (/meet, /private) exist. */
|
|
9
|
+
export const SCHEDULING_PAGES: boolean = false;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* subscribeNewsletterAction — Server Action paired with the
|
|
5
|
+
* `ailkSubscribeNewsletter` MCP tool (#6730 D9).
|
|
6
|
+
*
|
|
7
|
+
* `/newsletter` and the footer render the newsletter magnet's
|
|
8
|
+
* `NewsletterFormInline` bound to this action. Validates the address with
|
|
9
|
+
* its own `newsletterFormSchema` (the form's one field is the page's, not a
|
|
10
|
+
* wire contract — the `subscribe_newsletter` MCP tool takes the route body
|
|
11
|
+
* since #7172), calls `POST /v1/leads/subscribe` through
|
|
12
|
+
* `@working-theory/api-client`, and maps the outcome the same way
|
|
13
|
+
* `captureLeadAction` does: the API's 503 `lead_capture_not_configured` →
|
|
14
|
+
* `unavailable`; any other non-2xx or thrown error → the generic `error`;
|
|
15
|
+
* only a 2xx is `success`.
|
|
16
|
+
*
|
|
17
|
+
* Visitor forwarding (#7394): forwards the visitor exactly as
|
|
18
|
+
* `captureLeadAction` does, through `visitorForwardHeaders` and the
|
|
19
|
+
* server-only `AILK_VISITOR_FORWARD_KEY`; without them the call is made
|
|
20
|
+
* with one argument, as before.
|
|
21
|
+
*
|
|
22
|
+
* Through the magnet (#8847 D11): /newsletter, /unsubscribe's sibling footer
|
|
23
|
+
* signup and the page itself all render the workspace's `newsletter`
|
|
24
|
+
* magnet, so a signup goes through it. The action re-reads the magnet; a post
|
|
25
|
+
* without `consent=true` while that magnet has a consent field (or, with no
|
|
26
|
+
* magnet, ever) answers a `consent` field error and nothing is sent. When the
|
|
27
|
+
* magnet maps, the
|
|
28
|
+
* address and the consent go to `POST /v1/flows/newsletter/steps` — the step
|
|
29
|
+
* key, the email field's name and the consent field's purposes all come from
|
|
30
|
+
* the server's own read, never the post (SEC-8847-A) — which writes the lead,
|
|
31
|
+
* the consent ledger row and the magnet's welcome email. A 2xx that completes
|
|
32
|
+
* is success; a 503 `lead_capture_not_configured` or an unreachable API
|
|
33
|
+
* (status 0, 502, 504) is `unavailable`. Otherwise (a Free build has no flow
|
|
34
|
+
* route) it posts `leads.subscribe`, as before.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { flows, leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
38
|
+
import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
|
|
39
|
+
import { NEWSLETTER_FLOW_SLUG } from "@working-theory/validation";
|
|
40
|
+
import { cookies, headers } from "next/headers";
|
|
41
|
+
import { getLocale } from "next-intl/server";
|
|
42
|
+
import { z } from "zod";
|
|
43
|
+
|
|
44
|
+
import { checkRateLimit, clientIpFromHeaders } from "../src/lib/rate-limit";
|
|
45
|
+
|
|
46
|
+
import { consentVisitorHeader, readConsentVisitor } from "./consent-visitor";
|
|
47
|
+
import { inPageView, readServedMagnet } from "./in-page-magnet";
|
|
48
|
+
|
|
49
|
+
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
50
|
+
const CONSENT_REQUIRED = "Please accept the terms to subscribe.";
|
|
51
|
+
const RATE_LIMITED = "Too many signups from here. Please try again in a few minutes.";
|
|
52
|
+
// #8847 (security review) — the contact action's per-visitor budget: the hub
|
|
53
|
+
// sees this site's egress address on the step route, so one visitor must not
|
|
54
|
+
// spend the bucket every visitor shares.
|
|
55
|
+
const RATE_LIMIT_MAX_CALLS = 30;
|
|
56
|
+
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
|
|
57
|
+
|
|
58
|
+
/** The form's one field; zod's default message is the field error shown. */
|
|
59
|
+
const newsletterFormSchema = z.object({ email: z.string().email() });
|
|
60
|
+
|
|
61
|
+
/** The API's 503 for a deployment with nowhere to keep a lead. */
|
|
62
|
+
function isNotConfigured(res: { status: number; body: unknown }): boolean {
|
|
63
|
+
return (
|
|
64
|
+
res.status === 503 &&
|
|
65
|
+
typeof res.body === "object" &&
|
|
66
|
+
res.body !== null &&
|
|
67
|
+
(res.body as { error?: unknown }).error === "lead_capture_not_configured"
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** An API that could not be reached: a thrown call (0), a 502 or a 504. */
|
|
72
|
+
function isApiUnreachable(res: { status: number }): boolean {
|
|
73
|
+
return res.status === 0 || res.status === 502 || res.status === 504;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The signup through the served newsletter magnet, or `null` when it does not
|
|
78
|
+
* map (absent, draft, a Free build with no flow route, or not a one-field
|
|
79
|
+
* signup) — the caller then posts `leads.subscribe`.
|
|
80
|
+
*/
|
|
81
|
+
async function submitThroughMagnet(
|
|
82
|
+
email: string,
|
|
83
|
+
consented: boolean,
|
|
84
|
+
init: { headers: Record<string, string> } | undefined,
|
|
85
|
+
): Promise<LeadCaptureFormState | null> {
|
|
86
|
+
const magnet = await readServedMagnet(NEWSLETTER_FLOW_SLUG);
|
|
87
|
+
let locale = "en";
|
|
88
|
+
try {
|
|
89
|
+
locale = await getLocale();
|
|
90
|
+
} catch {
|
|
91
|
+
// No request-scoped locale — the default locale.
|
|
92
|
+
}
|
|
93
|
+
const view = magnet ? inPageView(magnet, locale, "newsletter") : null;
|
|
94
|
+
if (view === null || !("kind" in view.form)) return null;
|
|
95
|
+
const { emailField, consent } = view.form;
|
|
96
|
+
// A magnet without a consent field (a wizard-built newsletter) asks for none.
|
|
97
|
+
if (consent !== undefined && !consented) {
|
|
98
|
+
return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
|
|
99
|
+
}
|
|
100
|
+
const values: Record<string, unknown> = { [emailField]: email };
|
|
101
|
+
if (consent !== undefined) {
|
|
102
|
+
for (const purpose of consent.purposes) values[`${consent.field}.${purpose}`] = true;
|
|
103
|
+
}
|
|
104
|
+
const payload = { stepKey: view.stepKey, values, locale };
|
|
105
|
+
try {
|
|
106
|
+
const res = init
|
|
107
|
+
? await flows.submitStep(NEWSLETTER_FLOW_SLUG, payload, init)
|
|
108
|
+
: await flows.submitStep(NEWSLETTER_FLOW_SLUG, payload);
|
|
109
|
+
if (res.ok) return res.body.completed ? { kind: "success" } : { kind: "error", message: GENERIC_ERROR };
|
|
110
|
+
if (isNotConfigured(res) || isApiUnreachable(res)) return { kind: "unavailable" };
|
|
111
|
+
return { kind: "error", message: GENERIC_ERROR };
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// The api-client never attaches the request payload to a thrown error.
|
|
114
|
+
console.error("[subscribeNewsletterAction]", err);
|
|
115
|
+
return { kind: "unavailable" };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function subscribeNewsletterAction(
|
|
120
|
+
_prevState: LeadCaptureFormState,
|
|
121
|
+
formData: FormData,
|
|
122
|
+
): Promise<LeadCaptureFormState> {
|
|
123
|
+
// Honeypot: a bot that fills the hidden field gets a person's answer, and
|
|
124
|
+
// nothing is sent.
|
|
125
|
+
const honeypot = formData.get(LEAD_CAPTURE_HONEYPOT);
|
|
126
|
+
if (typeof honeypot === "string" && honeypot !== "") return { kind: "success" };
|
|
127
|
+
|
|
128
|
+
const parsed = newsletterFormSchema.safeParse({
|
|
129
|
+
email: formData.get("email"),
|
|
130
|
+
});
|
|
131
|
+
if (!parsed.success) {
|
|
132
|
+
return {
|
|
133
|
+
kind: "error",
|
|
134
|
+
fieldErrors: { email: parsed.error.issues[0]?.message ?? "Invalid email" },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
|
|
139
|
+
try {
|
|
140
|
+
requestHeaders = await headers();
|
|
141
|
+
} catch {
|
|
142
|
+
// No request scope to read — no forwarding, and the visitor is "unknown".
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// No readable address: skip the per-visitor limit rather than put every
|
|
146
|
+
// visitor in one shared bucket (the contact action's rule).
|
|
147
|
+
const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
|
|
148
|
+
if (
|
|
149
|
+
visitor !== "unknown" &&
|
|
150
|
+
!checkRateLimit(`newsletter:${visitor}`, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
|
|
151
|
+
) {
|
|
152
|
+
return { kind: "error", message: RATE_LIMITED };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let init: { headers: Record<string, string> } | undefined;
|
|
156
|
+
if (requestHeaders) {
|
|
157
|
+
const forwarded = visitorForwardHeaders(requestHeaders, process.env.AILK_VISITOR_FORWARD_KEY);
|
|
158
|
+
if (forwarded) init = { headers: forwarded };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// #8438 D12 — the banner's visitor id, so a later subscribe links its
|
|
162
|
+
// cookie choices to the client record. Absent or invalid → no header.
|
|
163
|
+
try {
|
|
164
|
+
const visitorId = readConsentVisitor(await cookies());
|
|
165
|
+
if (visitorId) init = { headers: { ...(init?.headers ?? {}), ...consentVisitorHeader(visitorId) } };
|
|
166
|
+
} catch {
|
|
167
|
+
// No request scope to read — subscribe with no visitor-link header.
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const consented = formData.get("consent") === "true";
|
|
171
|
+
// The step route keys its limit on the forwarded address, as the walk's
|
|
172
|
+
// action sends it (flow.actions.ts).
|
|
173
|
+
const stepInit =
|
|
174
|
+
visitor !== "unknown"
|
|
175
|
+
? { headers: { "x-forwarded-for": visitor, ...(init?.headers ?? {}) } }
|
|
176
|
+
: init;
|
|
177
|
+
const viaMagnet = await submitThroughMagnet(parsed.data.email, consented, stepInit);
|
|
178
|
+
if (viaMagnet !== null) return viaMagnet;
|
|
179
|
+
// The Free path renders the seeded config, whose consent box gates the post.
|
|
180
|
+
if (!consented) return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
|
|
181
|
+
|
|
182
|
+
const siteId = process.env.AILK_SITE_ID ?? "default";
|
|
183
|
+
const body = { siteId, source: "newsletter", email: parsed.data.email };
|
|
184
|
+
try {
|
|
185
|
+
const res = init
|
|
186
|
+
? await leads.subscribe(body, init)
|
|
187
|
+
: await leads.subscribe(body);
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
const error =
|
|
190
|
+
typeof res.body === "object" && res.body !== null
|
|
191
|
+
? (res.body as { error?: unknown }).error
|
|
192
|
+
: undefined;
|
|
193
|
+
if (res.status === 503 && error === "lead_capture_not_configured") {
|
|
194
|
+
return { kind: "unavailable" };
|
|
195
|
+
}
|
|
196
|
+
return { kind: "error", message: GENERIC_ERROR };
|
|
197
|
+
}
|
|
198
|
+
return { kind: "success" };
|
|
199
|
+
} catch (err) {
|
|
200
|
+
// The api-client never attaches the request payload to a thrown error,
|
|
201
|
+
// so logging `err` carries no subscriber address.
|
|
202
|
+
console.error("[subscribeNewsletterAction]", err);
|
|
203
|
+
return { kind: "error", message: GENERIC_ERROR };
|
|
204
|
+
}
|
|
205
|
+
}
|