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
|
@@ -10,13 +10,26 @@
|
|
|
10
10
|
* Canonical filename: contact.actions.ts (*.actions.ts per ADR 0013 / U0 spec
|
|
11
11
|
* parity-test regex `ACTION_FILE_RE = /\.actions\.ts$/`).
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* The honeypot is checked here, and so is a per-visitor limit (#8747): 30
|
|
14
|
+
* calls per 10 minutes per visitor address, before the write. From a server
|
|
15
|
+
* action the hub sees this site's egress address, so one visitor could
|
|
16
|
+
* otherwise drain the hub's per-IP budget for every visitor of the site. With
|
|
17
|
+
* no readable address the limit is skipped, never shared. The hub keeps its
|
|
18
|
+
* own per-IP limit on the route.
|
|
19
|
+
*
|
|
20
|
+
* The write (#8747): every lead goes through `writeLead`, the site's lead
|
|
21
|
+
* writer (`src/lib/lead-write.ts`), which marks it `payload.site =
|
|
22
|
+
* AILK_LEAD_SITE` and `payload.page = "contact"` (spread last, so no form field
|
|
23
|
+
* can override them) and sends no `siteId` — the API names the workspace
|
|
24
|
+
* itself, from the spoke key on a hub or its own site alias otherwise.
|
|
14
25
|
*
|
|
15
26
|
* Outcome (#6730 D8): the API's 503 `lead_capture_not_configured` — this
|
|
16
27
|
* deployment has nowhere to put a lead — maps to `{ kind: "unavailable" }`,
|
|
17
|
-
* which the shared `LeadCaptureForm` renders as the site's contact email.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
28
|
+
* which the shared `LeadCaptureForm` renders as the site's contact email. So
|
|
29
|
+
* does an API that cannot be reached (#8747): a thrown submit, a 502 or a 504
|
|
30
|
+
* — the message reached nobody, so the visitor is shown the email rather than
|
|
31
|
+
* a "try again". Every other non-2xx is the generic `error`; only a 2xx is
|
|
32
|
+
* ever `success`.
|
|
20
33
|
*
|
|
21
34
|
* Extra fields (#6730 D8a): a `contact-form` section can declare fields
|
|
22
35
|
* beyond name/email/message/phone (a `select`, say). Every other string
|
|
@@ -33,17 +46,25 @@
|
|
|
33
46
|
* second `*Action`-suffixed export, which would trip
|
|
34
47
|
* `apps/mcp/__tests__/parity.test.ts`'s Server-Action↔MCP-tool 1:1 count for
|
|
35
48
|
* a capability (lead capture) this repo already has an MCP twin for
|
|
36
|
-
* (`ailkCaptureLead`). Absent (the real `<form>` never sets it) →
|
|
37
|
-
*
|
|
49
|
+
* (`ailkCaptureLead`). Absent (the real `<form>` never sets it) → the
|
|
50
|
+
* built-in form's one standard source, `"contact-form"` (#8747; it was
|
|
51
|
+
* `"web"`, and the sites disagreed).
|
|
38
52
|
*
|
|
39
|
-
* Source allow-list and terms (#8661 D8): `/contact` stamps a
|
|
40
|
-
* drew from the Contact form magnet with a hidden `source` of
|
|
41
|
-
* `"flow:contact"`, so it lands as an Inbox submission of that magnet
|
|
42
|
-
*
|
|
43
|
-
* `
|
|
44
|
-
*
|
|
45
|
-
* intake
|
|
46
|
-
*
|
|
53
|
+
* Source allow-list and terms (#8661 D8, #8730): `/contact` stamps a
|
|
54
|
+
* submission it drew from the Contact form magnet with a hidden `source` of
|
|
55
|
+
* `"flow:contact"`, so it lands as an Inbox submission of that magnet (it
|
|
56
|
+
* reaches the Needs-you queue through the submissions read, not the message
|
|
57
|
+
* list — `flow:<slug>` is never a message source). The field is
|
|
58
|
+
* visitor-controlled, so only the contact sources (`CONTACT_LEAD_SOURCES`:
|
|
59
|
+
* `"contact-form"`, `"conversational-intake"`, `"flow:contact"`) are kept;
|
|
60
|
+
* anything else is sent as `"contact-form"`, a message source, so no
|
|
61
|
+
* visitor-set value drops a real message out of Needs-you. The source picks
|
|
62
|
+
* the stamp and never the gate (#8730 D2): EVERY post must carry
|
|
63
|
+
* `consent=true` — the form's terms box, or the intake's own terms turn — or
|
|
64
|
+
* it answers a `consent` field error, merged with the schema's, and nothing
|
|
65
|
+
* is sent. An accepted post tells the API so (`consent: true`, which the API
|
|
66
|
+
* requires of a contact lead and records on it); an API `400
|
|
67
|
+
* consent_required` is shown as the same field error.
|
|
47
68
|
*
|
|
48
69
|
* Visitor forwarding (#7394): on a spoke whose API is a hub, the call
|
|
49
70
|
* carries the visitor's IP and the spoke's `AILK_VISITOR_FORWARD_KEY`
|
|
@@ -52,20 +73,53 @@
|
|
|
52
73
|
* of the spoke host's. No key, no client address, or headers that cannot be
|
|
53
74
|
* read → the call is made with one argument, exactly as before; a failure to
|
|
54
75
|
* read headers never blocks a submission.
|
|
76
|
+
*
|
|
77
|
+
* The account link (#8757 D8): after a successful write, the API's 201 may
|
|
78
|
+
* carry `next` (D6). `{ completion: "invite" }` — the magnet invites the
|
|
79
|
+
* submitter to their account — answers `{ kind: "success", next: { href,
|
|
80
|
+
* label } }`, the href from `accountInviteHref` (the request's locale, the
|
|
81
|
+
* submitter's email, this site's sign-in provider) and the label the
|
|
82
|
+
* `contact.trackInAccount` message. Anything else — no `next`, a site with no
|
|
83
|
+
* sign-in, an email the hint schema refuses, a locale or message that cannot
|
|
84
|
+
* be read — answers exactly `{ kind: "success" }`: a written lead is never
|
|
85
|
+
* reported as a failure.
|
|
86
|
+
*
|
|
87
|
+
* Continue to checkout (#8757 D11): `{ completion: "checkout",
|
|
88
|
+
* checkoutOffering }` — the magnet sends the submitter on to the offering's
|
|
89
|
+
* standard checkout — calls `redirect("/api/checkout/<checkoutOffering>")`
|
|
90
|
+
* (a Server Action redirect, a 303). It runs only after the write resolved
|
|
91
|
+
* `ok`, and outside any `try`, since `redirect()` throws NEXT_REDIRECT by
|
|
92
|
+
* design (`checkout.actions.ts`'s rule). The key is re-checked with
|
|
93
|
+
* `flowCheckoutOfferingSchema` first; one that fails answers `{ kind:
|
|
94
|
+
* "success" }`, as `lead` does. The standard checkout is served on a spoke
|
|
95
|
+
* only (`TOPOLOGY`), so any other build answers `{ kind: "success" }` too,
|
|
96
|
+
* never a redirect to a 404. A failed write never redirects.
|
|
55
97
|
*/
|
|
56
98
|
|
|
57
|
-
import {
|
|
99
|
+
import { visitorForwardHeaders } from "@working-theory/api-client";
|
|
58
100
|
import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
|
|
59
101
|
import {
|
|
60
|
-
|
|
61
|
-
|
|
102
|
+
CONTACT_LEAD_SOURCES,
|
|
103
|
+
flowCheckoutOfferingSchema,
|
|
104
|
+
leadSubmitNextSchema,
|
|
62
105
|
submitLeadInputSchema,
|
|
63
106
|
} from "@working-theory/validation";
|
|
64
107
|
import { cookies, headers } from "next/headers";
|
|
108
|
+
import { redirect } from "next/navigation";
|
|
109
|
+
import { getLocale, getTranslations } from "next-intl/server";
|
|
65
110
|
|
|
111
|
+
import { accountInviteHref } from "../../../lib/account-invite";
|
|
66
112
|
import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
|
|
113
|
+
import { TOPOLOGY } from "../../../lib/topology.generated";
|
|
114
|
+
import { writeLead } from "../../../src/lib/lead-write";
|
|
115
|
+
import { checkRateLimit, clientIpFromHeaders } from "../../../src/lib/rate-limit";
|
|
67
116
|
|
|
68
117
|
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
118
|
+
const RATE_LIMITED = "Too many messages from here. Please try again in a few minutes.";
|
|
119
|
+
|
|
120
|
+
/** The per-visitor budget (#8747): the consumer sites' own values. */
|
|
121
|
+
const RATE_LIMIT_MAX_CALLS = 30;
|
|
122
|
+
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
|
|
69
123
|
|
|
70
124
|
/** Field names the action reads itself — never copied into `payload`. */
|
|
71
125
|
const RESERVED_FIELDS = new Set([
|
|
@@ -79,13 +133,8 @@ const RESERVED_FIELDS = new Set([
|
|
|
79
133
|
]);
|
|
80
134
|
const MAX_EXTRA_FIELDS = 20;
|
|
81
135
|
|
|
82
|
-
/** The
|
|
83
|
-
const
|
|
84
|
-
"web",
|
|
85
|
-
"conversational-intake",
|
|
86
|
-
flowLeadSource(CONTACT_FORM_FLOW_SLUG),
|
|
87
|
-
]);
|
|
88
|
-
const INTAKE_SOURCE = "conversational-intake";
|
|
136
|
+
/** The built-in form's source (#8747), and the one an unlisted value becomes. */
|
|
137
|
+
const DEFAULT_SOURCE = "contact-form";
|
|
89
138
|
/** The server's terms-error text. The form block shows its own copy for this key. */
|
|
90
139
|
const CONSENT_REQUIRED = "Please accept the terms to send your message.";
|
|
91
140
|
const MAX_EXTRA_FIELD_LENGTH = 2000;
|
|
@@ -108,6 +157,16 @@ function extraFields(formData: FormData): Record<string, string> | null {
|
|
|
108
157
|
return extra;
|
|
109
158
|
}
|
|
110
159
|
|
|
160
|
+
/** A 400 whose body names `consent_required` (#8730). */
|
|
161
|
+
function isConsentRequired(res: { status: number; body: unknown }): boolean {
|
|
162
|
+
return (
|
|
163
|
+
res.status === 400 &&
|
|
164
|
+
typeof res.body === "object" &&
|
|
165
|
+
res.body !== null &&
|
|
166
|
+
(res.body as { error?: unknown }).error === "consent_required"
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
111
170
|
/** A 503 whose body names `lead_capture_not_configured`. */
|
|
112
171
|
function isNotConfigured(res: { status: number; body: unknown }): boolean {
|
|
113
172
|
return (
|
|
@@ -118,6 +177,54 @@ function isNotConfigured(res: { status: number; body: unknown }): boolean {
|
|
|
118
177
|
);
|
|
119
178
|
}
|
|
120
179
|
|
|
180
|
+
/**
|
|
181
|
+
* The API could not be reached (#8747): `writeLead`'s `status: 0` (a thrown
|
|
182
|
+
* submit) or a gateway failure. A 503 that is not `lead_capture_not_configured`
|
|
183
|
+
* stays the generic error — the API answered.
|
|
184
|
+
*/
|
|
185
|
+
function isApiUnreachable(res: { status: number }): boolean {
|
|
186
|
+
return res.status === 0 || res.status === 502 || res.status === 504;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* #8757 D11 — the offering a `checkout` answer names, when its key passes the
|
|
191
|
+
* schema and this build serves the standard checkout (a spoke); else `null`.
|
|
192
|
+
*/
|
|
193
|
+
function checkoutOfferingOf(next: unknown): string | null {
|
|
194
|
+
if (TOPOLOGY !== "spoke") return null;
|
|
195
|
+
const parsed = leadSubmitNextSchema.safeParse(next);
|
|
196
|
+
if (!parsed.success || parsed.data.completion !== "checkout") return null;
|
|
197
|
+
const key = flowCheckoutOfferingSchema.safeParse(parsed.data.checkoutOffering);
|
|
198
|
+
return key.success ? key.data : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* #8757 D8 — the success state for a written lead: with the account link when
|
|
203
|
+
* the API answered `next: { completion: "invite" }` and this site can build
|
|
204
|
+
* one, else exactly `{ kind: "success" }`. Never throws.
|
|
205
|
+
*/
|
|
206
|
+
async function successState(
|
|
207
|
+
next: unknown,
|
|
208
|
+
email: string,
|
|
209
|
+
): Promise<Extract<LeadCaptureFormState, { kind: "success" }>> {
|
|
210
|
+
const parsed = leadSubmitNextSchema.safeParse(next);
|
|
211
|
+
if (!parsed.success || parsed.data.completion !== "invite") return { kind: "success" };
|
|
212
|
+
let locale = "en";
|
|
213
|
+
try {
|
|
214
|
+
locale = await getLocale();
|
|
215
|
+
} catch {
|
|
216
|
+
// No request-scoped locale — the default locale's link.
|
|
217
|
+
}
|
|
218
|
+
const href = accountInviteHref(locale, email);
|
|
219
|
+
if (href === null) return { kind: "success" };
|
|
220
|
+
try {
|
|
221
|
+
const t = await getTranslations("contact");
|
|
222
|
+
return { kind: "success", next: { href, label: t("trackInAccount") } };
|
|
223
|
+
} catch {
|
|
224
|
+
return { kind: "success" };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
121
228
|
export async function captureLeadAction(
|
|
122
229
|
_prevState: LeadCaptureFormState,
|
|
123
230
|
formData: FormData,
|
|
@@ -137,10 +244,12 @@ export async function captureLeadAction(
|
|
|
137
244
|
|
|
138
245
|
const posted = formData.get("source");
|
|
139
246
|
const source =
|
|
140
|
-
typeof posted === "string" &&
|
|
247
|
+
typeof posted === "string" && CONTACT_LEAD_SOURCES.has(posted) ? posted : DEFAULT_SOURCE;
|
|
141
248
|
|
|
249
|
+
// #8730 D2 — every source, the intake's included: it asks for the terms
|
|
250
|
+
// itself and posts consent=true like the form does.
|
|
142
251
|
const fieldErrors: Record<string, string> = {};
|
|
143
|
-
if (
|
|
252
|
+
if (formData.get("consent") !== "true") {
|
|
144
253
|
fieldErrors.consent = CONSENT_REQUIRED;
|
|
145
254
|
}
|
|
146
255
|
|
|
@@ -161,28 +270,35 @@ export async function captureLeadAction(
|
|
|
161
270
|
if (extra === null) return { kind: "error", message: GENERIC_ERROR };
|
|
162
271
|
|
|
163
272
|
const { name, email, message, phone } = parsed.data;
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
payload: {
|
|
170
|
-
...extra,
|
|
171
|
-
name,
|
|
172
|
-
...(message !== undefined && { message }),
|
|
173
|
-
...(phone !== undefined && { phone }),
|
|
174
|
-
},
|
|
273
|
+
const payload = {
|
|
274
|
+
...extra,
|
|
275
|
+
name,
|
|
276
|
+
...(message !== undefined && { message }),
|
|
277
|
+
...(phone !== undefined && { phone }),
|
|
175
278
|
};
|
|
176
279
|
|
|
177
|
-
let
|
|
280
|
+
let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
|
|
178
281
|
try {
|
|
179
|
-
|
|
180
|
-
await headers(),
|
|
181
|
-
process.env.AILK_VISITOR_FORWARD_KEY,
|
|
182
|
-
);
|
|
183
|
-
if (forwarded) init = { headers: forwarded };
|
|
282
|
+
requestHeaders = await headers();
|
|
184
283
|
} catch {
|
|
185
|
-
// No request scope to read —
|
|
284
|
+
// No request scope to read — no forwarding, and the visitor is "unknown".
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// No readable address: skip the per-visitor limit rather than put every
|
|
288
|
+
// visitor in one shared bucket a single caller could fill. The hub's own
|
|
289
|
+
// per-IP limit still applies.
|
|
290
|
+
const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
|
|
291
|
+
if (
|
|
292
|
+
visitor !== "unknown" &&
|
|
293
|
+
!checkRateLimit(`contact:${visitor}`, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
|
|
294
|
+
) {
|
|
295
|
+
return { kind: "error", message: RATE_LIMITED };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let init: { headers: Record<string, string> } | undefined;
|
|
299
|
+
if (requestHeaders) {
|
|
300
|
+
const forwarded = visitorForwardHeaders(requestHeaders, process.env.AILK_VISITOR_FORWARD_KEY);
|
|
301
|
+
if (forwarded) init = { headers: forwarded };
|
|
186
302
|
}
|
|
187
303
|
|
|
188
304
|
// #8438 D12 — the banner's visitor id, so a later form submit links its
|
|
@@ -195,20 +311,19 @@ export async function captureLeadAction(
|
|
|
195
311
|
// No request scope to read — submit with no visitor-link header.
|
|
196
312
|
}
|
|
197
313
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
314
|
+
// writeLead never throws: a thrown submit is logged there by error name
|
|
315
|
+
// only, with no field values (#4042), and resolves as `status: 0`.
|
|
316
|
+
const res = await writeLead({ source, email, page: "contact", consent: true, payload }, init);
|
|
317
|
+
if (!res.ok) {
|
|
318
|
+
if (isConsentRequired(res)) {
|
|
319
|
+
return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
|
|
203
320
|
}
|
|
204
|
-
return { kind: "
|
|
205
|
-
} catch (err) {
|
|
206
|
-
// Log server-side for observability (#4042, sibling of flow.actions.ts's
|
|
207
|
-
// #3989 fix). The api-client's post() never attaches the request payload
|
|
208
|
-
// to a thrown error (packages/api-client/src/http.ts), so logging `err`
|
|
209
|
-
// itself is safe — no submitted form fields (name/email/message/phone)
|
|
210
|
-
// are included.
|
|
211
|
-
console.error("[captureLeadAction]", err);
|
|
321
|
+
if (isNotConfigured(res) || isApiUnreachable(res)) return { kind: "unavailable" };
|
|
212
322
|
return { kind: "error", message: GENERIC_ERROR };
|
|
213
323
|
}
|
|
324
|
+
// #8757 D11 — after the write, outside any try: redirect() throws by design.
|
|
325
|
+
const checkoutOffering = checkoutOfferingOf(res.body.next);
|
|
326
|
+
if (checkoutOffering !== null) redirect(`/api/checkout/${checkoutOffering}`);
|
|
327
|
+
// #8847 D7 — the parsed name, which the magnet's ending fills `{name}` with.
|
|
328
|
+
return { ...(await successState(res.body.next, email)), name };
|
|
214
329
|
}
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* ids ARE the submission adapter's field-mapping contract; renaming a
|
|
12
12
|
* question id here requires updating that mapping too.
|
|
13
13
|
*
|
|
14
|
+
* A fourth, closed question (`consent`, #8730 D2) asks for the terms before
|
|
15
|
+
* anything is sent. The engine records a tapped pill as its LABEL, so the
|
|
16
|
+
* wrapper treats the answer as acceptance only when it equals
|
|
17
|
+
* `copy.consentAccept` exactly; any other answer — the decline pill or typed
|
|
18
|
+
* text — sends nothing and shows the form, whose own terms box decides.
|
|
19
|
+
*
|
|
14
20
|
* Copy is translated (next-intl "contact" namespace, "intake.*" keys —
|
|
15
21
|
* see apps/web/messages/en.json) rather than hardcoded, consistent with
|
|
16
22
|
* ContactForm's own `useTranslations("contact")` usage. The wrapper resolves
|
|
@@ -31,10 +37,18 @@ export interface ContactIntakeCopy {
|
|
|
31
37
|
messagePrompt: string;
|
|
32
38
|
emailAck: string;
|
|
33
39
|
emailPrompt: string;
|
|
40
|
+
consentAck: string;
|
|
41
|
+
consentPrompt: string;
|
|
42
|
+
/** The accept pill's label — the one answer that is terms acceptance. */
|
|
43
|
+
consentAccept: string;
|
|
44
|
+
consentDecline: string;
|
|
34
45
|
skipMessage: string;
|
|
35
46
|
completionMessage: string;
|
|
36
47
|
}
|
|
37
48
|
|
|
49
|
+
/** The accept option's value — its pill's test id is `…-pill-accept`. */
|
|
50
|
+
const INTAKE_CONSENT_ACCEPT = "accept";
|
|
51
|
+
|
|
38
52
|
export function buildContactIntakeScript(
|
|
39
53
|
copy: ContactIntakeCopy,
|
|
40
54
|
): IntakeScript {
|
|
@@ -59,6 +73,18 @@ export function buildContactIntakeScript(
|
|
|
59
73
|
acknowledgement: copy.emailAck,
|
|
60
74
|
prompt: copy.emailPrompt,
|
|
61
75
|
},
|
|
76
|
+
// #8730 D2 — the intake asks for the terms itself. Only the accept
|
|
77
|
+
// pill's label is acceptance; the wrapper reads `consent` by that id.
|
|
78
|
+
{
|
|
79
|
+
id: "consent",
|
|
80
|
+
kind: "closed",
|
|
81
|
+
acknowledgement: copy.consentAck,
|
|
82
|
+
prompt: copy.consentPrompt,
|
|
83
|
+
options: [
|
|
84
|
+
{ value: INTAKE_CONSENT_ACCEPT, label: copy.consentAccept },
|
|
85
|
+
{ value: "decline", label: copy.consentDecline },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
62
88
|
],
|
|
63
89
|
skip: { message: copy.skipMessage },
|
|
64
90
|
completion: { message: copy.completionMessage },
|
|
@@ -11,18 +11,25 @@
|
|
|
11
11
|
// API, an older API without the status route, or any other failed read is
|
|
12
12
|
// treated as accepting: the form renders, and its submit reports honestly.
|
|
13
13
|
//
|
|
14
|
-
// The form
|
|
15
|
-
// form magnet (`flows.get("contact")`, the public flow read — the API
|
|
16
|
-
// the workspace, the site alias keyless and a spoke by its key);
|
|
17
|
-
// page's `contact-form` section fields when it declares any; else
|
|
18
|
-
// built-in form: the seeded magnet's own config, its name, email and
|
|
19
|
-
// labels from the `contact` catalog.
|
|
20
|
-
// the
|
|
21
|
-
//
|
|
22
|
-
// the
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
14
|
+
// The form (#8661 D6, #8847 D7), first usable wins: the workspace's Contact
|
|
15
|
+
// form magnet (`flows.get("contact")`, the public flow read — the API
|
|
16
|
+
// resolves the workspace, the site alias keyless and a spoke by its key);
|
|
17
|
+
// else the page's `contact-form` section fields when it declares any; else
|
|
18
|
+
// the built-in form: the seeded magnet's own config, its name, email and
|
|
19
|
+
// message labels from the `contact` catalog. Every visible fact on the page
|
|
20
|
+
// comes from the magnet (#8847): its field step's title is the page's H1 and
|
|
21
|
+
// `<title>`, its body the line under it (`framedSources`), and its ending
|
|
22
|
+
// the success message. The built-in config stands in when the hub cannot be
|
|
23
|
+
// read, so a fresh deploy looks the same either way. The section's own
|
|
24
|
+
// `successMessage` and `submitLabel` apply only when its own fields are
|
|
25
|
+
// used. A magnet-driven post is stamped `source: "flow:contact"`, so it is
|
|
26
|
+
// an Inbox submission of the Contact form; the other two post as
|
|
27
|
+
// `contact-form` (#8747). The page is rendered on every request, but its two
|
|
28
|
+
// hub reads (the magnet and the capture status) are shared across requests
|
|
29
|
+
// for HUB_READ_TTL_MS (`lib/in-page-magnet.ts`, #8731). The terms statement
|
|
30
|
+
// links Terms and Privacy Policy from a page-built map — a magnet label can
|
|
31
|
+
// name a link key, never an href. Under the form, "Prefer email?" and the
|
|
32
|
+
// `_site.mdx` contact email, when the site has one.
|
|
26
33
|
//
|
|
27
34
|
// A `contact-form` section in the `column` variant (the minimalism Contact
|
|
28
35
|
// page, #8661 D12) gets the stacked form look and a Send that stays enabled,
|
|
@@ -31,7 +38,6 @@
|
|
|
31
38
|
// The chat-style intake (ConversationalIntakeWrapper, #4534) remains
|
|
32
39
|
// available to a site that prefers it. JSON-LD and translations handled by
|
|
33
40
|
// renderPage.
|
|
34
|
-
import { flows, leads } from "@working-theory/api-client";
|
|
35
41
|
import type {
|
|
36
42
|
getMdxSource as defaultGetMdxSource,
|
|
37
43
|
getPageSections as defaultGetPageSections,
|
|
@@ -49,22 +55,30 @@ import {
|
|
|
49
55
|
type LeadCaptureFormText,
|
|
50
56
|
localeHref,
|
|
51
57
|
} from "@working-theory/ui";
|
|
58
|
+
import { Link, Text } from "@working-theory/ui/primitives";
|
|
52
59
|
import {
|
|
53
60
|
CONTACT_FORM_FLOW_SLUG,
|
|
54
61
|
contactFormFlowConfig,
|
|
55
62
|
flowLeadSource,
|
|
56
|
-
type FlowConfig,
|
|
57
63
|
} from "@working-theory/validation";
|
|
58
64
|
import type { Metadata } from "next";
|
|
59
65
|
import { getTranslations } from "next-intl/server";
|
|
60
66
|
import type { ReactElement } from "react";
|
|
61
67
|
|
|
62
|
-
import {
|
|
68
|
+
import {
|
|
69
|
+
framedSources,
|
|
70
|
+
inPageView,
|
|
71
|
+
readCaptureStatus,
|
|
72
|
+
readServedMagnet,
|
|
73
|
+
type InPageView,
|
|
74
|
+
} from "../../../lib/in-page-magnet";
|
|
75
|
+
|
|
76
|
+
import { contactFormFromConfig, type ContactFormView } from "./contact-form-magnet";
|
|
63
77
|
import { contactFormText } from "./contact-form-text";
|
|
64
78
|
import { captureLeadAction } from "./contact.actions";
|
|
65
79
|
import { readSiteContactEmail } from "./site-contact-email";
|
|
66
80
|
|
|
67
|
-
// #8661 D6 —
|
|
81
|
+
// #8661 D6 — rendered on every request; the magnet read behind it is shared for HUB_READ_TTL_MS (#8731, lib/in-page-magnet.ts).
|
|
68
82
|
export const dynamic = "force-dynamic";
|
|
69
83
|
|
|
70
84
|
interface Props {
|
|
@@ -73,11 +87,6 @@ interface Props {
|
|
|
73
87
|
getPageSections?: typeof defaultGetPageSections;
|
|
74
88
|
}
|
|
75
89
|
|
|
76
|
-
type CaptureStatus = Extract<
|
|
77
|
-
Awaited<ReturnType<typeof leads.captureStatus>>,
|
|
78
|
-
{ ok: true }
|
|
79
|
-
>["body"]["data"];
|
|
80
|
-
|
|
81
90
|
type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
|
|
82
91
|
type ContactFormSection = Extract<Section, { type: "contact-form" }>;
|
|
83
92
|
|
|
@@ -113,36 +122,44 @@ function builtInFields(text: LeadCaptureFormText, locale: string): ContactFormFi
|
|
|
113
122
|
});
|
|
114
123
|
}
|
|
115
124
|
|
|
116
|
-
/** The API's capture status, or `null` for any failed read (see header). */
|
|
117
|
-
async function readCaptureStatus(): Promise<CaptureStatus | null> {
|
|
118
|
-
try {
|
|
119
|
-
const res = await leads.captureStatus();
|
|
120
|
-
return res.ok && res.body.ok === true ? res.body.data : null;
|
|
121
|
-
} catch {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
125
|
/**
|
|
127
|
-
* The
|
|
128
|
-
*
|
|
129
|
-
* property access sits inside the `try`, so a client with no `flows` is a
|
|
130
|
-
* failed read too.
|
|
126
|
+
* The page's view of the Contact form (#8847 D7): the served magnet's when it
|
|
127
|
+
* maps, else the built-in config's. `served` says which.
|
|
131
128
|
*/
|
|
132
|
-
async function
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
129
|
+
async function contactView(
|
|
130
|
+
locale: string,
|
|
131
|
+
fallbackHeadline: string,
|
|
132
|
+
): Promise<{ view: InPageView; served: boolean }> {
|
|
133
|
+
const magnet = await readServedMagnet(CONTACT_FORM_FLOW_SLUG);
|
|
134
|
+
const served = magnet ? inPageView(magnet, locale, "contact", fallbackHeadline) : null;
|
|
135
|
+
if (served) return { view: served, served: true };
|
|
136
|
+
// The built-in config always maps: its first step has name and email.
|
|
137
|
+
return {
|
|
138
|
+
view: inPageView(contactFormFlowConfig, locale, "contact", fallbackHeadline)!,
|
|
139
|
+
served: false,
|
|
140
|
+
};
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
export async function generateMetadata({
|
|
142
144
|
params,
|
|
143
|
-
|
|
145
|
+
getMdxSource,
|
|
146
|
+
getPageSections,
|
|
147
|
+
}: Props): Promise<Metadata> {
|
|
144
148
|
const { locale } = await params;
|
|
145
|
-
|
|
149
|
+
const t = await getTranslations({ locale, namespace: "contact" });
|
|
150
|
+
const { view } = await contactView(locale, t("successMessage"));
|
|
151
|
+
// #8847 D7 — the `<title>` is the magnet's step title, like the H1.
|
|
152
|
+
const framed = framedSources({
|
|
153
|
+
getMdxSource: getMdxSource ?? loadMdxSource,
|
|
154
|
+
getPageSections: getPageSections ?? loadPageSections,
|
|
155
|
+
view,
|
|
156
|
+
});
|
|
157
|
+
return renderPageMetadata({
|
|
158
|
+
type: "ContactPage",
|
|
159
|
+
slug: "contact",
|
|
160
|
+
locale,
|
|
161
|
+
getMdxSource: framed.getMdxSource,
|
|
162
|
+
});
|
|
146
163
|
}
|
|
147
164
|
|
|
148
165
|
export default async function Page({
|
|
@@ -151,17 +168,22 @@ export default async function Page({
|
|
|
151
168
|
getPageSections,
|
|
152
169
|
}: Props): Promise<ReactElement> {
|
|
153
170
|
const { locale } = await params;
|
|
154
|
-
const [status, t
|
|
171
|
+
const [status, t] = await Promise.all([
|
|
155
172
|
readCaptureStatus(),
|
|
156
173
|
getTranslations({ locale, namespace: "contact" }),
|
|
157
|
-
readContactMagnet(),
|
|
158
174
|
]);
|
|
159
175
|
const accepting = status?.contact ?? true;
|
|
160
176
|
const contactEmail = readSiteContactEmail();
|
|
161
177
|
const text = contactFormText(t);
|
|
178
|
+
const { view, served } = await contactView(locale, text.success);
|
|
179
|
+
const framed = framedSources({
|
|
180
|
+
getMdxSource: getMdxSource ?? loadMdxSource,
|
|
181
|
+
getPageSections: getPageSections ?? loadPageSections,
|
|
182
|
+
view,
|
|
183
|
+
});
|
|
162
184
|
|
|
163
|
-
const source = await
|
|
164
|
-
const sections = source ? await
|
|
185
|
+
const source = await framed.getMdxSource("ContactPage", "contact", locale);
|
|
186
|
+
const sections = source ? await framed.getPageSections(source.frontmatter) : [];
|
|
165
187
|
const formSection = sections.find(
|
|
166
188
|
(s): s is ContactFormSection => s.type === "contact-form",
|
|
167
189
|
);
|
|
@@ -173,19 +195,23 @@ export default async function Page({
|
|
|
173
195
|
(lead?.type === "hero" && lead.data.variant === "split" ? lead.id : undefined);
|
|
174
196
|
|
|
175
197
|
// #8661 D6 — the magnet's fields, else the section's, else the built-in form.
|
|
176
|
-
const
|
|
177
|
-
const declared =
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
198
|
+
const form = view.form as ContactFormView;
|
|
199
|
+
const declared =
|
|
200
|
+
!served && formSection && formSection.data.fields.length > 0 ? formSection : undefined;
|
|
201
|
+
const fields: ContactFormField[] = served
|
|
202
|
+
? form.fields
|
|
203
|
+
: declared
|
|
204
|
+
? sectionFields(declared)
|
|
205
|
+
: builtInFields(text, locale);
|
|
206
|
+
// #8847 D7 — the section's submit label and success message apply only to
|
|
207
|
+
// its own fields; otherwise the magnet's (or the built-in config's) ending.
|
|
208
|
+
const submitLabel = served ? form.submitLabel : declared?.data.submitLabel;
|
|
183
209
|
const column = formSection?.data.variant === "column";
|
|
184
210
|
|
|
185
|
-
const
|
|
211
|
+
const leadForm = accepting ? (
|
|
186
212
|
<LeadCaptureForm
|
|
187
213
|
action={captureLeadAction}
|
|
188
|
-
text={served
|
|
214
|
+
text={served && form.consentStatement ? { ...text, consentStatement: form.consentStatement } : text}
|
|
189
215
|
termsHref={localeHref(locale, "/terms")}
|
|
190
216
|
policies={{
|
|
191
217
|
terms: localeHref(locale, "/terms"),
|
|
@@ -195,7 +221,7 @@ export default async function Page({
|
|
|
195
221
|
fallbackEmail={fallbackEmail}
|
|
196
222
|
fields={fields}
|
|
197
223
|
submitLabel={submitLabel}
|
|
198
|
-
|
|
224
|
+
{...(declared ? { successMessage: declared.data.successMessage } : { ending: view.ending })}
|
|
199
225
|
{...(served && { source: flowLeadSource(CONTACT_FORM_FLOW_SLUG) })}
|
|
200
226
|
{...(column && { look: "column" as const, consentGate: "on-submit" as const })}
|
|
201
227
|
/>
|
|
@@ -209,14 +235,27 @@ export default async function Page({
|
|
|
209
235
|
}}
|
|
210
236
|
/>
|
|
211
237
|
);
|
|
238
|
+
// #8847 D7 — one muted line under the form; absent with no address.
|
|
239
|
+
const surface = (
|
|
240
|
+
<>
|
|
241
|
+
{leadForm}
|
|
242
|
+
{contactEmail !== undefined && (
|
|
243
|
+
<div className="mt-block-tight text-center" data-testid="contact-prefer-email">
|
|
244
|
+
<Text tone="muted" variant="body-sm">
|
|
245
|
+
{t("preferEmail")} <Link href={`mailto:${contactEmail}`}>{contactEmail}</Link>
|
|
246
|
+
</Text>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</>
|
|
250
|
+
);
|
|
212
251
|
|
|
213
252
|
const rendered = await renderPage({
|
|
214
253
|
type: "ContactPage",
|
|
215
254
|
slug: "contact",
|
|
216
255
|
locale,
|
|
217
256
|
translationsNamespace: "contact",
|
|
218
|
-
|
|
219
|
-
|
|
257
|
+
getMdxSource: framed.getMdxSource,
|
|
258
|
+
getPageSections: framed.getPageSections,
|
|
220
259
|
...(slotted !== undefined && { sectionSlots: { [slotted]: surface } }),
|
|
221
260
|
});
|
|
222
261
|
return (
|