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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HubSignInGate — the signed-out account gate of a spoke that signs in
|
|
3
|
-
* through its hub (#7894 D10).
|
|
3
|
+
* through its hub (#7894 D10; #8790 AC-1/AC-2).
|
|
4
4
|
*
|
|
5
5
|
* A Server Component, rendered by the `(authed)` layout through the generated
|
|
6
6
|
* gate seam (`signed-out-gate.generated.tsx`, its `signIn: "hub"` render). It
|
|
@@ -9,16 +9,26 @@
|
|
|
9
9
|
* link is a plain anchor (the `Link` primitive), so nothing prefetches the
|
|
10
10
|
* route and starts a flow the visitor did not ask for.
|
|
11
11
|
*
|
|
12
|
+
* #8790 — the gate reads as one of the site's inner pages, not a bare
|
|
13
|
+
* full-height stop: the site's inner-page header (the back arrow at the start
|
|
14
|
+
* edge, the mark centred — the shape `Nav bar="bare"` draws, composed here
|
|
15
|
+
* from the same `NavBackLink` and `siteLogo()`), a heading at the label size
|
|
16
|
+
* (`heading-2`), and an optional quiet prospect line a site turns on by
|
|
17
|
+
* adding `account.gate.prospectPrompt` + `prospectLink` + `prospectHref`
|
|
18
|
+
* to its messages — absent, the shipped default, renders nothing.
|
|
19
|
+
* The document title is the account page's job (`generateMetadata` there
|
|
20
|
+
* reads the session and names this state "Sign in · <site>").
|
|
21
|
+
*
|
|
12
22
|
* Not configured (`hubSessionConfig()` is `null`: a client id, secret or
|
|
13
23
|
* session secret missing), it renders the account area's existing "sign-in
|
|
14
24
|
* isn't available yet" state, whose one action leads back to the site.
|
|
15
25
|
*/
|
|
16
|
-
|
|
17
26
|
import { localeHref } from "@working-theory/ui";
|
|
27
|
+
import { NavBackLink } from "@working-theory/ui/chrome";
|
|
18
28
|
import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
|
|
19
29
|
import { getTranslations } from "next-intl/server";
|
|
20
30
|
import type { ReactElement } from "react";
|
|
21
|
-
|
|
31
|
+
import { siteLogo } from "../site-logo";
|
|
22
32
|
import { hubSessionConfig } from "./config";
|
|
23
33
|
import { HUB_SESSION_FALLBACK_PATH } from "./session";
|
|
24
34
|
|
|
@@ -40,40 +50,86 @@ export async function HubSignInGate({
|
|
|
40
50
|
const next =
|
|
41
51
|
requestHeaders.get("x-pathname") ??
|
|
42
52
|
localeHref(locale, HUB_SESSION_FALLBACK_PATH);
|
|
53
|
+
const home = localeHref(locale, "/");
|
|
54
|
+
// The three prospect keys are optional extensions of the typed catalog (the
|
|
55
|
+
// shipped en.json carries none — every leaf must be a non-empty string), so
|
|
56
|
+
// presence is the switch; hence the cast on `has`.
|
|
57
|
+
type Key = Parameters<typeof t.has>[0];
|
|
58
|
+
const has = (key: string): boolean => t.has(key as Key);
|
|
59
|
+
const prospect =
|
|
60
|
+
has("prospectPrompt") && has("prospectLink") && has("prospectHref")
|
|
61
|
+
? {
|
|
62
|
+
prompt: t("prospectPrompt" as Key),
|
|
63
|
+
linkLabel: t("prospectLink" as Key),
|
|
64
|
+
href: t("prospectHref" as Key),
|
|
65
|
+
}
|
|
66
|
+
: null;
|
|
43
67
|
|
|
44
68
|
return (
|
|
45
69
|
<div
|
|
46
|
-
className="flex min-h-screen flex-col
|
|
70
|
+
className="flex min-h-screen flex-col"
|
|
47
71
|
data-testid="account-sign-in-gate"
|
|
48
72
|
>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
{/* The site's inner-page header: back arrow at the start, the mark centred (#8790 AC-1). */}
|
|
74
|
+
<header
|
|
75
|
+
className="grid h-16 grid-cols-[1fr_auto_1fr] items-center px-5 md:px-8"
|
|
76
|
+
data-testid="account-sign-in-gate-header"
|
|
77
|
+
>
|
|
78
|
+
<div className="justify-self-start">
|
|
79
|
+
<NavBackLink href={home} label={t("homeLink")} />
|
|
80
|
+
</div>
|
|
81
|
+
<Link
|
|
82
|
+
href={home}
|
|
83
|
+
variant="unstyled"
|
|
84
|
+
className="inline-flex h-7 items-center [&>img]:h-full [&>img]:w-auto [&>img]:object-contain"
|
|
85
|
+
data-testid="account-sign-in-gate-mark"
|
|
86
|
+
>
|
|
87
|
+
{siteLogo()}
|
|
88
|
+
</Link>
|
|
89
|
+
<div />
|
|
90
|
+
</header>
|
|
91
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-section text-center">
|
|
92
|
+
<Heading level={1} visual={2}>
|
|
93
|
+
{configured ? t("hubHeadline") : t("emptyHeadline")}
|
|
94
|
+
</Heading>
|
|
95
|
+
<Text variant="body-sm" tone="muted" className="max-w-xs">
|
|
96
|
+
{configured ? t("hubDescription") : t("emptyDescription")}
|
|
97
|
+
</Text>
|
|
98
|
+
{configured ? (
|
|
99
|
+
// The primary action: 44px on a phone, 36px from md up.
|
|
100
|
+
<Button asChild className="max-md:min-h-11">
|
|
101
|
+
<Link
|
|
102
|
+
href={`${HUB_SIGN_IN_ROUTE}?next=${encodeURIComponent(next)}`}
|
|
103
|
+
variant="bare"
|
|
104
|
+
data-testid="account-hub-sign-in"
|
|
105
|
+
>
|
|
106
|
+
{t("hubSignIn")}
|
|
107
|
+
</Link>
|
|
108
|
+
</Button>
|
|
109
|
+
) : (
|
|
110
|
+
<Button asChild variant="secondary" className="max-md:min-h-11">
|
|
111
|
+
<Link
|
|
112
|
+
href={home}
|
|
113
|
+
variant="bare"
|
|
114
|
+
data-testid="account-sign-in-gate-home"
|
|
115
|
+
>
|
|
116
|
+
{t("homeLink")}
|
|
117
|
+
</Link>
|
|
118
|
+
</Button>
|
|
119
|
+
)}
|
|
120
|
+
{configured && prospect ? (
|
|
121
|
+
// A wrapping div carries the hook: `Text` forwards no data-* attributes.
|
|
122
|
+
<div data-testid="account-sign-in-gate-prospect">
|
|
123
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
124
|
+
{prospect.prompt}{" "}
|
|
125
|
+
<Link href={prospect.href} variant="inline">
|
|
126
|
+
{prospect.linkLabel}
|
|
127
|
+
</Link>
|
|
128
|
+
.
|
|
129
|
+
</Text>
|
|
130
|
+
</div>
|
|
131
|
+
) : null}
|
|
132
|
+
</div>
|
|
77
133
|
</div>
|
|
78
134
|
);
|
|
79
135
|
}
|
|
@@ -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,42 @@
|
|
|
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.
|
|
16
|
+
*
|
|
17
|
+
* Fail-safe direction, as `isAuthPath()`: an absent or empty pathname
|
|
18
|
+
* answers `false`, so a missing `x-pathname` never strips a page's chrome.
|
|
19
|
+
*
|
|
20
|
+
* Core, not a module slice: the layout every scaffold carries imports it, and
|
|
21
|
+
* it names no paid code. No Next.js import — a pure function.
|
|
22
|
+
*/
|
|
23
|
+
export function isMagnetWalkPath(
|
|
24
|
+
pathname: string | undefined | null,
|
|
25
|
+
locales: readonly string[],
|
|
26
|
+
): boolean {
|
|
27
|
+
if (!pathname) return false;
|
|
28
|
+
const path = pathname.split(/[?#]/)[0] ?? "";
|
|
29
|
+
const bare =
|
|
30
|
+
locales.length > 0
|
|
31
|
+
? path.replace(new RegExp(`^/(${locales.join("|")})(?=/|$)`), "")
|
|
32
|
+
: path;
|
|
33
|
+
const segments = bare.split("/").filter((segment) => segment.length > 0);
|
|
34
|
+
if (segments.length === 0) return false;
|
|
35
|
+
if (segments[segments.length - 1] === "start") return true;
|
|
36
|
+
if (segments[0] === "reports" && segments.length === 2) return true;
|
|
37
|
+
return (
|
|
38
|
+
segments[0] === "careers" &&
|
|
39
|
+
segments[segments.length - 1] === "apply" &&
|
|
40
|
+
(segments.length === 2 || segments.length === 3)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -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;
|