create-ailk 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.json +186 -8
- package/component-catalog.md +9 -7
- package/dist/add-module.js +20 -14
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +294 -12
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +12 -3
- package/dist/derive-architecture.js +20 -8
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +5 -5
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +3 -2
- package/dist/lib/scaffold-render-sweep.js +5 -8
- package/dist/module-architecture.js +99 -9
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/programmatic.js +19 -5
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/spoke-checkout.d.ts +65 -41
- package/dist/spoke-checkout.js +208 -430
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +52 -26
- package/dist/sync-routes.d.ts +38 -1
- package/dist/sync-routes.js +187 -13
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +8 -4
- package/dist/topology.js +25 -1
- package/dist/update.js +15 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +9 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +27 -0
- package/templates/apps/api/CLAUDE.md +87 -17
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/lib/stripe.ts +74 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/spec.ts +33 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +268 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +164 -7
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +245 -36
- package/templates/apps/api/src/server.ts +163 -46
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1383 -133
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +431 -47
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +164 -17
- package/templates/apps/api/src/services/plan-offerings.ts +125 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +27 -15
- package/templates/apps/mcp/CLAUDE.md +22 -14
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +24 -31
- package/templates/apps/web/CLAUDE.md +393 -9
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +295 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +120 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +23 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +319 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +141 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +424 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +72 -11
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +160 -13
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +466 -36
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +172 -57
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -60
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +341 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +7 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +101 -25
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +16 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +7 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +42 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +202 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +449 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +71 -20
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +69 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +64 -1
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +62 -3
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* subscribeNewsletterAction — Server Action paired with the
|
|
5
|
+
* `ailkSubscribeNewsletter` MCP tool (#6730 D9).
|
|
6
|
+
*
|
|
7
|
+
* `/newsletter` and the footer render the newsletter magnet's
|
|
8
|
+
* `NewsletterFormInline` bound to this action. Validates the address with
|
|
9
|
+
* the tool's own input schema, calls `POST /v1/leads/subscribe` through
|
|
10
|
+
* `@working-theory/api-client`, and maps the outcome the same way
|
|
11
|
+
* `captureLeadAction` does: the API's 503 `lead_capture_not_configured` →
|
|
12
|
+
* `unavailable`; any other non-2xx or thrown error → the generic `error`;
|
|
13
|
+
* only a 2xx is `success`.
|
|
14
|
+
*
|
|
15
|
+
* Visitor forwarding (#7394): forwards the visitor exactly as
|
|
16
|
+
* `captureLeadAction` does, through `visitorForwardHeaders` and the
|
|
17
|
+
* server-only `AILK_VISITOR_FORWARD_KEY`; without them the call is made
|
|
18
|
+
* with one argument, as before.
|
|
19
|
+
*
|
|
20
|
+
* Through the magnet (#8847 D11): /newsletter, /unsubscribe's sibling footer
|
|
21
|
+
* signup and the page itself all render the workspace's `newsletter`
|
|
22
|
+
* magnet, so a signup goes through it. The action re-reads the magnet; a post
|
|
23
|
+
* without `consent=true` while that magnet has a consent field (or, with no
|
|
24
|
+
* magnet, ever) answers a `consent` field error and nothing is sent. When the
|
|
25
|
+
* magnet maps, the
|
|
26
|
+
* address and the consent go to `POST /v1/flows/newsletter/steps` — the step
|
|
27
|
+
* key, the email field's name and the consent field's purposes all come from
|
|
28
|
+
* the server's own read, never the post (SEC-8847-A) — which writes the lead,
|
|
29
|
+
* the consent ledger row and the magnet's welcome email. A 2xx that completes
|
|
30
|
+
* is success; a 503 `lead_capture_not_configured` or an unreachable API
|
|
31
|
+
* (status 0, 502, 504) is `unavailable`. Otherwise (a Free build has no flow
|
|
32
|
+
* route) it posts `leads.subscribe`, as before.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { flows, leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
36
|
+
import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
|
|
37
|
+
import {
|
|
38
|
+
NEWSLETTER_FLOW_SLUG,
|
|
39
|
+
subscribeNewsletterInputSchema,
|
|
40
|
+
} from "@working-theory/validation";
|
|
41
|
+
import { cookies, headers } from "next/headers";
|
|
42
|
+
import { getLocale } from "next-intl/server";
|
|
43
|
+
|
|
44
|
+
import { checkRateLimit, clientIpFromHeaders } from "../src/lib/rate-limit";
|
|
45
|
+
|
|
46
|
+
import { consentVisitorHeader, readConsentVisitor } from "./consent-visitor";
|
|
47
|
+
import { inPageView, readServedMagnet } from "./in-page-magnet";
|
|
48
|
+
|
|
49
|
+
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
50
|
+
const CONSENT_REQUIRED = "Please accept the terms to subscribe.";
|
|
51
|
+
const RATE_LIMITED = "Too many signups from here. Please try again in a few minutes.";
|
|
52
|
+
// #8847 (security review) — the contact action's per-visitor budget: the hub
|
|
53
|
+
// sees this site's egress address on the step route, so one visitor must not
|
|
54
|
+
// spend the bucket every visitor shares.
|
|
55
|
+
const RATE_LIMIT_MAX_CALLS = 30;
|
|
56
|
+
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
|
|
57
|
+
|
|
58
|
+
/** The API's 503 for a deployment with nowhere to keep a lead. */
|
|
59
|
+
function isNotConfigured(res: { status: number; body: unknown }): boolean {
|
|
60
|
+
return (
|
|
61
|
+
res.status === 503 &&
|
|
62
|
+
typeof res.body === "object" &&
|
|
63
|
+
res.body !== null &&
|
|
64
|
+
(res.body as { error?: unknown }).error === "lead_capture_not_configured"
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** An API that could not be reached: a thrown call (0), a 502 or a 504. */
|
|
69
|
+
function isApiUnreachable(res: { status: number }): boolean {
|
|
70
|
+
return res.status === 0 || res.status === 502 || res.status === 504;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The signup through the served newsletter magnet, or `null` when it does not
|
|
75
|
+
* map (absent, draft, a Free build with no flow route, or not a one-field
|
|
76
|
+
* signup) — the caller then posts `leads.subscribe`.
|
|
77
|
+
*/
|
|
78
|
+
async function submitThroughMagnet(
|
|
79
|
+
email: string,
|
|
80
|
+
consented: boolean,
|
|
81
|
+
init: { headers: Record<string, string> } | undefined,
|
|
82
|
+
): Promise<LeadCaptureFormState | null> {
|
|
83
|
+
const magnet = await readServedMagnet(NEWSLETTER_FLOW_SLUG);
|
|
84
|
+
let locale = "en";
|
|
85
|
+
try {
|
|
86
|
+
locale = await getLocale();
|
|
87
|
+
} catch {
|
|
88
|
+
// No request-scoped locale — the default locale.
|
|
89
|
+
}
|
|
90
|
+
const view = magnet ? inPageView(magnet, locale, "newsletter") : null;
|
|
91
|
+
if (view === null || !("kind" in view.form)) return null;
|
|
92
|
+
const { emailField, consent } = view.form;
|
|
93
|
+
// A magnet without a consent field (a wizard-built newsletter) asks for none.
|
|
94
|
+
if (consent !== undefined && !consented) {
|
|
95
|
+
return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
|
|
96
|
+
}
|
|
97
|
+
const values: Record<string, unknown> = { [emailField]: email };
|
|
98
|
+
if (consent !== undefined) {
|
|
99
|
+
for (const purpose of consent.purposes) values[`${consent.field}.${purpose}`] = true;
|
|
100
|
+
}
|
|
101
|
+
const payload = { stepKey: view.stepKey, values, locale };
|
|
102
|
+
try {
|
|
103
|
+
const res = init
|
|
104
|
+
? await flows.submitStep(NEWSLETTER_FLOW_SLUG, payload, init)
|
|
105
|
+
: await flows.submitStep(NEWSLETTER_FLOW_SLUG, payload);
|
|
106
|
+
if (res.ok) return res.body.completed ? { kind: "success" } : { kind: "error", message: GENERIC_ERROR };
|
|
107
|
+
if (isNotConfigured(res) || isApiUnreachable(res)) return { kind: "unavailable" };
|
|
108
|
+
return { kind: "error", message: GENERIC_ERROR };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
// The api-client never attaches the request payload to a thrown error.
|
|
111
|
+
console.error("[subscribeNewsletterAction]", err);
|
|
112
|
+
return { kind: "unavailable" };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function subscribeNewsletterAction(
|
|
117
|
+
_prevState: LeadCaptureFormState,
|
|
118
|
+
formData: FormData,
|
|
119
|
+
): Promise<LeadCaptureFormState> {
|
|
120
|
+
// Honeypot: a bot that fills the hidden field gets a person's answer, and
|
|
121
|
+
// nothing is sent.
|
|
122
|
+
const honeypot = formData.get(LEAD_CAPTURE_HONEYPOT);
|
|
123
|
+
if (typeof honeypot === "string" && honeypot !== "") return { kind: "success" };
|
|
124
|
+
|
|
125
|
+
const parsed = subscribeNewsletterInputSchema.safeParse({
|
|
126
|
+
email: formData.get("email"),
|
|
127
|
+
});
|
|
128
|
+
if (!parsed.success) {
|
|
129
|
+
return {
|
|
130
|
+
kind: "error",
|
|
131
|
+
fieldErrors: { email: parsed.error.issues[0]?.message ?? "Invalid email" },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
|
|
136
|
+
try {
|
|
137
|
+
requestHeaders = await headers();
|
|
138
|
+
} catch {
|
|
139
|
+
// No request scope to read — no forwarding, and the visitor is "unknown".
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// No readable address: skip the per-visitor limit rather than put every
|
|
143
|
+
// visitor in one shared bucket (the contact action's rule).
|
|
144
|
+
const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
|
|
145
|
+
if (
|
|
146
|
+
visitor !== "unknown" &&
|
|
147
|
+
!checkRateLimit(`newsletter:${visitor}`, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
|
|
148
|
+
) {
|
|
149
|
+
return { kind: "error", message: RATE_LIMITED };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let init: { headers: Record<string, string> } | undefined;
|
|
153
|
+
if (requestHeaders) {
|
|
154
|
+
const forwarded = visitorForwardHeaders(requestHeaders, process.env.AILK_VISITOR_FORWARD_KEY);
|
|
155
|
+
if (forwarded) init = { headers: forwarded };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// #8438 D12 — the banner's visitor id, so a later subscribe links its
|
|
159
|
+
// cookie choices to the client record. Absent or invalid → no header.
|
|
160
|
+
try {
|
|
161
|
+
const visitorId = readConsentVisitor(await cookies());
|
|
162
|
+
if (visitorId) init = { headers: { ...(init?.headers ?? {}), ...consentVisitorHeader(visitorId) } };
|
|
163
|
+
} catch {
|
|
164
|
+
// No request scope to read — subscribe with no visitor-link header.
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const consented = formData.get("consent") === "true";
|
|
168
|
+
// The step route keys its limit on the forwarded address, as the walk's
|
|
169
|
+
// action sends it (flow.actions.ts).
|
|
170
|
+
const stepInit =
|
|
171
|
+
visitor !== "unknown"
|
|
172
|
+
? { headers: { "x-forwarded-for": visitor, ...(init?.headers ?? {}) } }
|
|
173
|
+
: init;
|
|
174
|
+
const viaMagnet = await submitThroughMagnet(parsed.data.email, consented, stepInit);
|
|
175
|
+
if (viaMagnet !== null) return viaMagnet;
|
|
176
|
+
// The Free path renders the seeded config, whose consent box gates the post.
|
|
177
|
+
if (!consented) return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
|
|
178
|
+
|
|
179
|
+
const siteId = process.env.AILK_SITE_ID ?? "default";
|
|
180
|
+
const body = { siteId, source: "newsletter", email: parsed.data.email };
|
|
181
|
+
try {
|
|
182
|
+
const res = init
|
|
183
|
+
? await leads.subscribe(body, init)
|
|
184
|
+
: await leads.subscribe(body);
|
|
185
|
+
if (!res.ok) {
|
|
186
|
+
const error =
|
|
187
|
+
typeof res.body === "object" && res.body !== null
|
|
188
|
+
? (res.body as { error?: unknown }).error
|
|
189
|
+
: undefined;
|
|
190
|
+
if (res.status === 503 && error === "lead_capture_not_configured") {
|
|
191
|
+
return { kind: "unavailable" };
|
|
192
|
+
}
|
|
193
|
+
return { kind: "error", message: GENERIC_ERROR };
|
|
194
|
+
}
|
|
195
|
+
return { kind: "success" };
|
|
196
|
+
} catch (err) {
|
|
197
|
+
// The api-client never attaches the request payload to a thrown error,
|
|
198
|
+
// so logging `err` carries no subscriber address.
|
|
199
|
+
console.error("[subscribeNewsletterAction]", err);
|
|
200
|
+
return { kind: "error", message: GENERIC_ERROR };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace shown set → the Pricing section's data (#8925, spec
|
|
3
|
+
* docs/specs/8925-map-the-workspace-shown-set-spec.md).
|
|
4
|
+
*
|
|
5
|
+
* One pure function, `offeringsPricing`, turns the Offerings shown set (spec
|
|
6
|
+
* 8876 D4, served by #8924) into two Pricing section fragments: one plan card
|
|
7
|
+
* per `ailkGroup: "plan"` Product and one add-on tile per `"addon"` Product,
|
|
8
|
+
* in the snapshot's order, each keyed by the Product's `{plan}` so `?plan=`
|
|
9
|
+
* (#8842) can match it. #8927 places the fragments into `/pricing`.
|
|
10
|
+
*
|
|
11
|
+
* This is the one place Stripe text crosses into page markup, so contract
|
|
12
|
+
* docs/specs/8879-the-offerings-security-contract-key-spec.md binds it:
|
|
13
|
+
* - T2 — every Stripe string is emitted as a plain text field, with format
|
|
14
|
+
* characters removed; nothing is parsed as HTML or Markdown (D8).
|
|
15
|
+
* - T3 — every link is `OFFERINGS_CHECKOUT_PATH` plus a lookup key that
|
|
16
|
+
* `parseLookupKey` accepts; no href is built from any other field (D5).
|
|
17
|
+
* - §3.7 — `yearly` reads as annual and `once` as one-time at render, never
|
|
18
|
+
* in the key (D2).
|
|
19
|
+
*
|
|
20
|
+
* The module holds no user-visible copy: the CTA label, the recommended label
|
|
21
|
+
* and the period phrasings arrive in `labels` (D9, customer-facing-i18n).
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
parseLookupKey,
|
|
25
|
+
lookupKeyPeriodSchema,
|
|
26
|
+
workspaceShownSetSchema,
|
|
27
|
+
type LookupKeyPeriod,
|
|
28
|
+
type OfferingsPrice,
|
|
29
|
+
type OfferingsProduct,
|
|
30
|
+
type PageSection,
|
|
31
|
+
} from "@working-theory/validation";
|
|
32
|
+
|
|
33
|
+
/** The period words, read by name from their one home (#8876 AC-9). */
|
|
34
|
+
const PERIOD = lookupKeyPeriodSchema.enum;
|
|
35
|
+
|
|
36
|
+
type PricingSectionData = Extract<PageSection, { type: "pricing" }>["data"];
|
|
37
|
+
type PricingPlanCard = PricingSectionData["plans"][number];
|
|
38
|
+
/** `AddonCard` ignores `key`; it is carried so a tile names its `{plan}` too. */
|
|
39
|
+
type PricingAddonTile = NonNullable<PricingSectionData["addons"]>[number] & {
|
|
40
|
+
key: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface OfferingsPricingLabels {
|
|
44
|
+
/** The CTA label on every card and tile. */
|
|
45
|
+
cta: string;
|
|
46
|
+
/** The recommended plan's pill and the recommended add-on's eyebrow. */
|
|
47
|
+
recommended: string;
|
|
48
|
+
/** A formatted amount → its monthly, annual and one-time phrasings. */
|
|
49
|
+
monthly(amount: string): string;
|
|
50
|
+
annual(amount: string): string;
|
|
51
|
+
oneTime(amount: string): string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OfferingsPricing {
|
|
55
|
+
plans: PricingSectionData | null;
|
|
56
|
+
addons: PricingSectionData | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The route every Offerings CTA links to, followed by a grammar key. */
|
|
60
|
+
export const OFFERINGS_CHECKOUT_PATH = "/api/offerings/checkout";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The CTA target for `lookupKey`, or `null` when the key is outside the
|
|
64
|
+
* grammar. A grammar key is `[a-z0-9_-]` only, so it needs no encoding.
|
|
65
|
+
*/
|
|
66
|
+
export function offeringsCheckoutHref(lookupKey: string): string | null {
|
|
67
|
+
return parseLookupKey(lookupKey)
|
|
68
|
+
? `${OFFERINGS_CHECKOUT_PATH}/${lookupKey}`
|
|
69
|
+
: null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether `lookupKey` is a grammar key of a Price in a current, non-empty
|
|
74
|
+
* shown set — the keys `/pricing`'s cards are built from (#9051 D4).
|
|
75
|
+
*/
|
|
76
|
+
export function isShownLookupKey(
|
|
77
|
+
shownSet: unknown,
|
|
78
|
+
lookupKey: string,
|
|
79
|
+
): boolean {
|
|
80
|
+
if (!parseLookupKey(lookupKey)) return false;
|
|
81
|
+
const parsed = shownSetSchema.safeParse(shownSet);
|
|
82
|
+
if (!parsed.success) return false;
|
|
83
|
+
const { state, offerings } = parsed.data;
|
|
84
|
+
if (state !== "current" || offerings.length === 0) return false;
|
|
85
|
+
return offerings.some((o) => o.prices.some((p) => p.lookupKey === lookupKey));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Stripe's zero- and three-decimal currencies. The spoke's legacy buy route
|
|
89
|
+
// held the only other copy across apps/web and @working-theory/spoke-checkout,
|
|
90
|
+
// and is retired (#9142). The table stays here, not in that package: a hub and
|
|
91
|
+
// a spoke without commerce carry /pricing but not the package.
|
|
92
|
+
const ZERO_DECIMAL_CURRENCIES = new Set([
|
|
93
|
+
"bif",
|
|
94
|
+
"clp",
|
|
95
|
+
"djf",
|
|
96
|
+
"gnf",
|
|
97
|
+
"jpy",
|
|
98
|
+
"kmf",
|
|
99
|
+
"krw",
|
|
100
|
+
"mga",
|
|
101
|
+
"pyg",
|
|
102
|
+
"rwf",
|
|
103
|
+
"ugx",
|
|
104
|
+
"vnd",
|
|
105
|
+
"vuv",
|
|
106
|
+
"xaf",
|
|
107
|
+
"xof",
|
|
108
|
+
"xpf",
|
|
109
|
+
]);
|
|
110
|
+
const THREE_DECIMAL_CURRENCIES = new Set(["bhd", "jod", "kwd", "omr", "tnd"]);
|
|
111
|
+
|
|
112
|
+
function currencyExponent(currency: string): number {
|
|
113
|
+
if (ZERO_DECIMAL_CURRENCIES.has(currency)) return 0;
|
|
114
|
+
if (THREE_DECIMAL_CURRENCIES.has(currency)) return 3;
|
|
115
|
+
return 2;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* An amount in Stripe's minor units, formatted for `locale` (D7). A whole
|
|
120
|
+
* amount drops its fraction digits; a locale `Intl` refuses falls back to `en`.
|
|
121
|
+
*/
|
|
122
|
+
export function formatMinorUnits(
|
|
123
|
+
amount: number,
|
|
124
|
+
currency: string,
|
|
125
|
+
locale: string,
|
|
126
|
+
): string {
|
|
127
|
+
const code = currency.toLowerCase();
|
|
128
|
+
const exponent = currencyExponent(code);
|
|
129
|
+
const value = amount / 10 ** exponent;
|
|
130
|
+
const options: Intl.NumberFormatOptions = {
|
|
131
|
+
style: "currency",
|
|
132
|
+
currency: code.toUpperCase(),
|
|
133
|
+
maximumFractionDigits: exponent,
|
|
134
|
+
minimumFractionDigits: Number.isInteger(value) ? 0 : exponent,
|
|
135
|
+
};
|
|
136
|
+
let format: Intl.NumberFormat;
|
|
137
|
+
try {
|
|
138
|
+
format = new Intl.NumberFormat(locale, options);
|
|
139
|
+
} catch {
|
|
140
|
+
format = new Intl.NumberFormat("en", options);
|
|
141
|
+
}
|
|
142
|
+
return format.format(value);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Cc, Cf, Zl, Zp and Cs — the T1 classes. The input was normalized once by
|
|
146
|
+
// #8876, so this changes nothing there; it is the last guard before markup.
|
|
147
|
+
const FORMAT_CHARACTERS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}]/gu;
|
|
148
|
+
|
|
149
|
+
function stripFormatCharacters(text: string, keepNewlines = false): string {
|
|
150
|
+
return text.replace(FORMAT_CHARACTERS, (ch) =>
|
|
151
|
+
keepNewlines && ch === "\n" ? ch : "",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function features(product: OfferingsProduct): string[] {
|
|
156
|
+
return product.marketingFeatures
|
|
157
|
+
.map((feature) => stripFormatCharacters(feature))
|
|
158
|
+
.filter((feature) => feature.length > 0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function priceFor(
|
|
162
|
+
product: OfferingsProduct,
|
|
163
|
+
period: LookupKeyPeriod,
|
|
164
|
+
): OfferingsPrice | undefined {
|
|
165
|
+
return product.prices.find((p) => p.period === period);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The Price a card shows in monthly mode: `monthly`, else `yearly`, else `once`. */
|
|
169
|
+
function shownPrice(product: OfferingsProduct): OfferingsPrice | undefined {
|
|
170
|
+
return (
|
|
171
|
+
priceFor(product, PERIOD.monthly) ??
|
|
172
|
+
priceFor(product, PERIOD.yearly) ??
|
|
173
|
+
priceFor(product, PERIOD.once)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function phrase(
|
|
178
|
+
price: OfferingsPrice,
|
|
179
|
+
locale: string,
|
|
180
|
+
labels: OfferingsPricingLabels,
|
|
181
|
+
): string {
|
|
182
|
+
const amount = formatMinorUnits(price.amount, price.currency, locale);
|
|
183
|
+
if (price.period === PERIOD.monthly) return labels.monthly(amount);
|
|
184
|
+
if (price.period === PERIOD.yearly) return labels.annual(amount);
|
|
185
|
+
return labels.oneTime(amount);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Only the first `recommended` Product of a group is highlighted (D4). Each
|
|
190
|
+
* offering's own flag is read; the shown set's top-level `recommended` ids
|
|
191
|
+
* carry the same fact and are deliberately not consulted a second time.
|
|
192
|
+
*/
|
|
193
|
+
function firstRecommended(products: OfferingsProduct[]): string | undefined {
|
|
194
|
+
return products.find((p) => p.recommended)?.plan;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function planCard(
|
|
198
|
+
product: OfferingsProduct,
|
|
199
|
+
highlighted: boolean,
|
|
200
|
+
locale: string,
|
|
201
|
+
labels: OfferingsPricingLabels,
|
|
202
|
+
): PricingPlanCard | null {
|
|
203
|
+
const shown = shownPrice(product);
|
|
204
|
+
const href = shown ? offeringsCheckoutHref(shown.lookupKey) : null;
|
|
205
|
+
if (!href) return null;
|
|
206
|
+
|
|
207
|
+
const monthly = priceFor(product, PERIOD.monthly);
|
|
208
|
+
const yearly = priceFor(product, PERIOD.yearly);
|
|
209
|
+
const once = priceFor(product, PERIOD.once);
|
|
210
|
+
const price: PricingPlanCard["price"] = {};
|
|
211
|
+
if (monthly) price.monthly = phrase(monthly, locale, labels);
|
|
212
|
+
if (yearly) price.annual = phrase(yearly, locale, labels);
|
|
213
|
+
if (once && !monthly && !yearly) price.custom = phrase(once, locale, labels);
|
|
214
|
+
|
|
215
|
+
const card: PricingPlanCard = {
|
|
216
|
+
key: product.plan,
|
|
217
|
+
name: stripFormatCharacters(product.name),
|
|
218
|
+
price,
|
|
219
|
+
features: features(product),
|
|
220
|
+
cta: { label: labels.cta, href },
|
|
221
|
+
};
|
|
222
|
+
const description =
|
|
223
|
+
product.description && stripFormatCharacters(product.description, true);
|
|
224
|
+
if (description) card.description = description;
|
|
225
|
+
if (product.unitLabel)
|
|
226
|
+
card.priceUnit = stripFormatCharacters(product.unitLabel);
|
|
227
|
+
if (product.note) card.priceSubhead = stripFormatCharacters(product.note);
|
|
228
|
+
if (product.termsNote)
|
|
229
|
+
card.priceFootnote = stripFormatCharacters(product.termsNote);
|
|
230
|
+
// Annual mode shows the yearly Price only when a monthly one is beside it,
|
|
231
|
+
// so only then does the card need a second link (D5, D6).
|
|
232
|
+
const annualHref =
|
|
233
|
+
monthly && yearly && offeringsCheckoutHref(yearly.lookupKey);
|
|
234
|
+
if (annualHref) card.annualCtaHref = annualHref;
|
|
235
|
+
if (highlighted) {
|
|
236
|
+
card.highlighted = true;
|
|
237
|
+
card.highlightStyle = "pill";
|
|
238
|
+
card.highlightLabel = labels.recommended;
|
|
239
|
+
}
|
|
240
|
+
return card;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function addonTile(
|
|
244
|
+
product: OfferingsProduct,
|
|
245
|
+
highlighted: boolean,
|
|
246
|
+
locale: string,
|
|
247
|
+
labels: OfferingsPricingLabels,
|
|
248
|
+
): PricingAddonTile | null {
|
|
249
|
+
const shown = shownPrice(product);
|
|
250
|
+
const href = shown && offeringsCheckoutHref(shown.lookupKey);
|
|
251
|
+
if (!shown || !href) return null;
|
|
252
|
+
|
|
253
|
+
const tile: PricingAddonTile = {
|
|
254
|
+
key: product.plan,
|
|
255
|
+
name: stripFormatCharacters(product.name),
|
|
256
|
+
price: phrase(shown, locale, labels),
|
|
257
|
+
features: features(product),
|
|
258
|
+
cta: { label: labels.cta, href },
|
|
259
|
+
};
|
|
260
|
+
if (product.unitLabel)
|
|
261
|
+
tile.qualifier = stripFormatCharacters(product.unitLabel);
|
|
262
|
+
if (highlighted) {
|
|
263
|
+
tile.eyebrow = labels.recommended;
|
|
264
|
+
tile.checkTone = "primary";
|
|
265
|
+
}
|
|
266
|
+
return tile;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// #8927 D3 — `GET /v1/content/shown-offerings` sends the shown set without
|
|
270
|
+
// `workspaceId` and `organizationId` (spec 8924 D3), which the strict schema
|
|
271
|
+
// requires. Both ids become optional; an id that is present is still
|
|
272
|
+
// validated, every other field is still required and an unknown key is still
|
|
273
|
+
// refused.
|
|
274
|
+
const shownSetSchema = workspaceShownSetSchema.partial({
|
|
275
|
+
workspaceId: true,
|
|
276
|
+
organizationId: true,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
function isPresent<T>(value: T | null): value is T {
|
|
280
|
+
return value !== null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The shown set as Pricing section data, or `null` when there is nothing to
|
|
285
|
+
* show: a value the shown-set schema refuses, a snapshot that is not
|
|
286
|
+
* `current`, or no offerings. Each fragment is `null` when its group is empty.
|
|
287
|
+
*/
|
|
288
|
+
export function offeringsPricing(
|
|
289
|
+
shownSet: unknown,
|
|
290
|
+
options: { locale: string; labels: OfferingsPricingLabels },
|
|
291
|
+
): OfferingsPricing | null {
|
|
292
|
+
const parsed = shownSetSchema.safeParse(shownSet);
|
|
293
|
+
if (!parsed.success) return null;
|
|
294
|
+
const { state, offerings } = parsed.data;
|
|
295
|
+
if (state !== "current" || offerings.length === 0) return null;
|
|
296
|
+
const { locale, labels } = options;
|
|
297
|
+
|
|
298
|
+
const planProducts = offerings.filter((o) => o.ailkGroup === "plan");
|
|
299
|
+
const addonProducts = offerings.filter((o) => o.ailkGroup === "addon");
|
|
300
|
+
const recommendedPlan = firstRecommended(planProducts);
|
|
301
|
+
const recommendedAddon = firstRecommended(addonProducts);
|
|
302
|
+
|
|
303
|
+
const plans = planProducts
|
|
304
|
+
.map((p) => planCard(p, p.plan === recommendedPlan, locale, labels))
|
|
305
|
+
.filter(isPresent);
|
|
306
|
+
const addons = addonProducts
|
|
307
|
+
.map((p) => addonTile(p, p.plan === recommendedAddon, locale, labels))
|
|
308
|
+
.filter(isPresent);
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
plans:
|
|
312
|
+
plans.length > 0
|
|
313
|
+
? {
|
|
314
|
+
variant: "three-tiers-with-toggle",
|
|
315
|
+
cardLayout: "centered",
|
|
316
|
+
billingToggle: plans.some(
|
|
317
|
+
(card) =>
|
|
318
|
+
card.price.monthly !== undefined &&
|
|
319
|
+
card.price.annual !== undefined,
|
|
320
|
+
),
|
|
321
|
+
plans,
|
|
322
|
+
}
|
|
323
|
+
: null,
|
|
324
|
+
addons: addons.length > 0 ? { variant: "addons", plans: [], addons } : null,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The posting a `/careers/<slug>/apply` page applies to, read by its title
|
|
3
|
+
* (#8840 D11). The read sits here, not in a page, so the structured-data walk
|
|
4
|
+
* does not take an apply page for a JobPosting page: the posting's own JSON-LD
|
|
5
|
+
* is `/careers/<slug>`'s. In `lib/` (#8849 D9), not a route directory, so
|
|
6
|
+
* every scaffold carries it: the `signal-collectors` walk and the Free apply
|
|
7
|
+
* page (`careers/[slug]/[action]`) both read it.
|
|
8
|
+
*/
|
|
9
|
+
import { getMdxSource } from "@working-theory/page-renderer";
|
|
10
|
+
|
|
11
|
+
/** The posting's title in `locale`, or `null` when no posting has this slug. */
|
|
12
|
+
export async function postingTitle(slug: string, locale: string): Promise<string | null> {
|
|
13
|
+
const source = await getMdxSource("JobPosting", slug, locale);
|
|
14
|
+
if (source === null) return null;
|
|
15
|
+
const title = (source.frontmatter as { title?: unknown }).title;
|
|
16
|
+
return typeof title === "string" && title.trim() !== "" ? title : null;
|
|
17
|
+
}
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
* `lib/license-key-store.ts`, which hands `prismaForAuth.siteSetting` to the
|
|
10
10
|
* licence client's SiteSetting store for the ONE deployment-wide
|
|
11
11
|
* `license.renewed_key` row (#6677 DD-6 — a narrow, named exception).
|
|
12
|
+
*
|
|
13
|
+
* A third use rides on `auth.ts`'s own (#8765 D4, SEC-8765-K):
|
|
14
|
+
* `lib/sign-up-agreements.ts`'s account hook writes the new user's `sign_up`
|
|
15
|
+
* consent grants through `consentGrant.createMany` on the transaction client
|
|
16
|
+
* Better Auth's sign-up transaction runs on — only rows for the user that
|
|
17
|
+
* transaction is creating, never a read and never any other row. It imports
|
|
18
|
+
* nothing from here; it reaches the client through the adapter.
|
|
19
|
+
*
|
|
12
20
|
* Every other business-logic caller goes through apps/api, never the
|
|
13
21
|
* database directly (architecture.yaml `apps/web` may_not_import_from
|
|
14
22
|
* `database`; the single-file singleton here is the documented exception,
|