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
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
* without calling api-client
|
|
9
9
|
* - captureLeadAction returns generic error on API failure
|
|
10
10
|
* - Parity: captureLeadAction is paired with ailkCaptureLead per ADR 0013
|
|
11
|
+
* - #8747: the lead is marked payload.site / payload.page and sends no
|
|
12
|
+
* siteId; a per-visitor limit runs before the write; an unreachable API is
|
|
13
|
+
* `unavailable`; the built-in form's source is `contact-form`
|
|
11
14
|
*/
|
|
12
15
|
|
|
16
|
+
import { __resetRateLimit } from "../../../../src/lib/rate-limit";
|
|
13
17
|
import { captureLeadAction } from "../contact.actions.js";
|
|
14
18
|
|
|
15
19
|
// Mock @working-theory/api-client. `visitorForwardHeaders` stays real (#7394)
|
|
@@ -29,6 +33,50 @@ jest.mock("next/headers", () => ({
|
|
|
29
33
|
headers: jest.fn().mockResolvedValue({ get: () => null }),
|
|
30
34
|
}));
|
|
31
35
|
|
|
36
|
+
// #8757 D8 — the thank-you's account link reads the request's locale and the
|
|
37
|
+
// `contact` messages through next-intl, and the generated sign-in provider.
|
|
38
|
+
// Both are mocked here, per case.
|
|
39
|
+
jest.mock("next-intl/server", () => {
|
|
40
|
+
const messages = require("../../../../messages/en.json") as {
|
|
41
|
+
contact: Record<string, unknown>;
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
__esModule: true,
|
|
45
|
+
getLocale: jest.fn().mockResolvedValue("en"),
|
|
46
|
+
getTranslations: jest.fn(async (namespace: string) => {
|
|
47
|
+
if (namespace !== "contact") throw new Error(`unexpected namespace ${namespace}`);
|
|
48
|
+
return (key: string) => {
|
|
49
|
+
const value = messages.contact[key];
|
|
50
|
+
return typeof value === "string" ? value : `contact.${key}`;
|
|
51
|
+
};
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// #8757 D11 — a `checkout` magnet redirects after the write. Next's
|
|
57
|
+
// `redirect()` throws NEXT_REDIRECT; the mock throws the same way so a case
|
|
58
|
+
// can read the target and prove nothing ran after it.
|
|
59
|
+
const mockRedirect = jest.fn((url: string): never => {
|
|
60
|
+
throw new Error(`NEXT_REDIRECT:${url}`);
|
|
61
|
+
});
|
|
62
|
+
jest.mock("next/navigation", () => ({
|
|
63
|
+
__esModule: true,
|
|
64
|
+
redirect: (url: string) => mockRedirect(url),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
let mockSignInProvider: "site" | "hub" | "none" = "site";
|
|
68
|
+
let mockTopology: "hub" | "spoke" = "spoke";
|
|
69
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: SIGN_IN_PROVIDER and TOPOLOGY are generated per-build constants (#8757 D8, D11), not runtime inputs; no env or HTTP surface selects them.
|
|
70
|
+
jest.mock("../../../../lib/topology.generated", () => ({
|
|
71
|
+
__esModule: true,
|
|
72
|
+
get SIGN_IN_PROVIDER() {
|
|
73
|
+
return mockSignInProvider;
|
|
74
|
+
},
|
|
75
|
+
get TOPOLOGY() {
|
|
76
|
+
return mockTopology;
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
|
|
32
80
|
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
33
81
|
import { headers } from "next/headers";
|
|
34
82
|
const mockSubmit = leads.submit as jest.Mock;
|
|
@@ -37,6 +85,7 @@ const mockHeaders = headers as jest.Mock;
|
|
|
37
85
|
afterEach(() => {
|
|
38
86
|
jest.clearAllMocks();
|
|
39
87
|
mockHeaders.mockResolvedValue({ get: () => null });
|
|
88
|
+
__resetRateLimit();
|
|
40
89
|
});
|
|
41
90
|
|
|
42
91
|
function makeFormData(fields: Record<string, string>): FormData {
|
|
@@ -58,6 +107,17 @@ const VALID_FIELDS = {
|
|
|
58
107
|
const INITIAL_STATE = null;
|
|
59
108
|
|
|
60
109
|
describe("captureLeadAction — valid input", () => {
|
|
110
|
+
it("#8847 D7 — the success state carries the posted name, for the ending's {name}", async () => {
|
|
111
|
+
mockSubmit.mockResolvedValueOnce({
|
|
112
|
+
ok: true,
|
|
113
|
+
body: { id: "lead_abc123", persisted: "db" },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
117
|
+
|
|
118
|
+
expect(result).toStrictEqual({ kind: "success", name: "Jane Smith" });
|
|
119
|
+
});
|
|
120
|
+
|
|
61
121
|
it("returns success state when api-client responds ok", async () => {
|
|
62
122
|
mockSubmit.mockResolvedValueOnce({
|
|
63
123
|
ok: true,
|
|
@@ -151,25 +211,10 @@ describe("captureLeadAction — API error", () => {
|
|
|
151
211
|
});
|
|
152
212
|
});
|
|
153
213
|
|
|
154
|
-
|
|
214
|
+
// #4042 — a thrown submit leaves a server-side trace; #8747 — it is the
|
|
215
|
+
// API being unreachable, so the visitor is shown the contact email.
|
|
216
|
+
it("a thrown submit is logged with no submitted field, and is unavailable", async () => {
|
|
155
217
|
mockSubmit.mockRejectedValueOnce(new Error("Network error"));
|
|
156
|
-
|
|
157
|
-
const result = await captureLeadAction(
|
|
158
|
-
INITIAL_STATE,
|
|
159
|
-
makeFormData(VALID_FIELDS),
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
expect(result).toMatchObject({
|
|
163
|
-
kind: "error",
|
|
164
|
-
message: expect.any(String),
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
// #4042 — the catch-all previously discarded a thrown error silently,
|
|
169
|
-
// leaving zero server-side trace of a production integration failure.
|
|
170
|
-
it("logs the thrown error server-side with a [captureLeadAction] prefix, with no PII from the submitted fields", async () => {
|
|
171
|
-
const thrown = new Error("Network error");
|
|
172
|
-
mockSubmit.mockRejectedValueOnce(thrown);
|
|
173
218
|
const consoleErrorSpy = jest
|
|
174
219
|
.spyOn(console, "error")
|
|
175
220
|
.mockImplementation(() => undefined);
|
|
@@ -179,17 +224,14 @@ describe("captureLeadAction — API error", () => {
|
|
|
179
224
|
makeFormData(VALID_FIELDS),
|
|
180
225
|
);
|
|
181
226
|
|
|
182
|
-
expect(consoleErrorSpy).
|
|
227
|
+
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
183
228
|
const loggedArgs = consoleErrorSpy.mock.calls.flat();
|
|
184
229
|
for (const value of Object.values(VALID_FIELDS)) {
|
|
185
230
|
for (const arg of loggedArgs) {
|
|
186
231
|
expect(String(arg)).not.toContain(value);
|
|
187
232
|
}
|
|
188
233
|
}
|
|
189
|
-
expect(result).
|
|
190
|
-
kind: "error",
|
|
191
|
-
message: expect.any(String),
|
|
192
|
-
});
|
|
234
|
+
expect(result).toEqual({ kind: "unavailable" });
|
|
193
235
|
|
|
194
236
|
consoleErrorSpy.mockRestore();
|
|
195
237
|
});
|
|
@@ -239,7 +281,7 @@ describe("captureLeadAction — outcome mapping (#6730)", () => {
|
|
|
239
281
|
|
|
240
282
|
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
241
283
|
|
|
242
|
-
expect(result).toEqual({ kind: "success" });
|
|
284
|
+
expect(result).toEqual({ kind: "success", name: expect.any(String) });
|
|
243
285
|
});
|
|
244
286
|
});
|
|
245
287
|
|
|
@@ -271,7 +313,7 @@ describe("captureLeadAction — declared extra fields (#6730 D8a)", () => {
|
|
|
271
313
|
source: string;
|
|
272
314
|
payload: Record<string, unknown>;
|
|
273
315
|
};
|
|
274
|
-
expect(Object.keys(body.payload).sort()).toEqual(["message", "name"]);
|
|
316
|
+
expect(Object.keys(body.payload).sort()).toEqual(["message", "name", "page"]);
|
|
275
317
|
expect(body.source).toBe("conversational-intake");
|
|
276
318
|
});
|
|
277
319
|
|
|
@@ -351,7 +393,7 @@ describe("captureLeadAction — visitor forwarding (#7394)", () => {
|
|
|
351
393
|
|
|
352
394
|
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
353
395
|
|
|
354
|
-
expect(result).toEqual({ kind: "success" });
|
|
396
|
+
expect(result).toEqual({ kind: "success", name: expect.any(String) });
|
|
355
397
|
const expected = visitorForwardHeaders(incoming, FORWARD_KEY);
|
|
356
398
|
expect(expected).toEqual({
|
|
357
399
|
"x-ailk-visitor-ip": "203.0.113.5",
|
|
@@ -366,7 +408,7 @@ describe("captureLeadAction — visitor forwarding (#7394)", () => {
|
|
|
366
408
|
|
|
367
409
|
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
368
410
|
|
|
369
|
-
expect(result).toEqual({ kind: "success" });
|
|
411
|
+
expect(result).toEqual({ kind: "success", name: expect.any(String) });
|
|
370
412
|
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
371
413
|
expect(mockSubmit.mock.calls[0]).toHaveLength(1);
|
|
372
414
|
});
|
|
@@ -407,7 +449,7 @@ describe("captureLeadAction — consent and source (#8661 D8)", () => {
|
|
|
407
449
|
makeFormData({ ...VALID_FIELDS, source: "flow:contact", subject: "Support" }),
|
|
408
450
|
);
|
|
409
451
|
|
|
410
|
-
expect(result).toEqual({ kind: "success" });
|
|
452
|
+
expect(result).toEqual({ kind: "success", name: expect.any(String) });
|
|
411
453
|
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
412
454
|
const body = mockSubmit.mock.calls[0][0] as {
|
|
413
455
|
source: string;
|
|
@@ -417,28 +459,416 @@ describe("captureLeadAction — consent and source (#8661 D8)", () => {
|
|
|
417
459
|
expect(body.payload.subject).toBe("Support");
|
|
418
460
|
});
|
|
419
461
|
|
|
420
|
-
it.each(["flow:other", "flow:onboarding", "admin", "WEB"])(
|
|
421
|
-
"an unknown source (%s) is sent as
|
|
462
|
+
it.each(["flow:other", "flow:onboarding", "admin", "WEB", "web"])(
|
|
463
|
+
"an unknown source (%s) is sent as contact-form",
|
|
422
464
|
async (source) => {
|
|
423
465
|
mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
424
466
|
|
|
425
467
|
await captureLeadAction(INITIAL_STATE, makeFormData({ ...VALID_FIELDS, source }));
|
|
426
468
|
|
|
427
|
-
expect((mockSubmit.mock.calls[0][0] as { source: string }).source).toBe("
|
|
469
|
+
expect((mockSubmit.mock.calls[0][0] as { source: string }).source).toBe("contact-form");
|
|
470
|
+
},
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
// #8730 D2/D4 — the hidden source picks the stamp, never the gate: the
|
|
474
|
+
// intake captures consent itself and posts consent=true like the form.
|
|
475
|
+
it("source=conversational-intake without consent is refused, and nothing is sent", async () => {
|
|
476
|
+
const result = await captureLeadAction(
|
|
477
|
+
INITIAL_STATE,
|
|
478
|
+
makeFormData({ ...UNTICKED, source: "conversational-intake" }),
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
expect(result).toEqual({
|
|
482
|
+
kind: "error",
|
|
483
|
+
fieldErrors: { consent: expect.any(String) },
|
|
484
|
+
});
|
|
485
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it.each(["contact-form", "conversational-intake", "flow:contact", "anything-else"])(
|
|
489
|
+
"no source value (%s) switches the check off",
|
|
490
|
+
async (source) => {
|
|
491
|
+
const result = await captureLeadAction(
|
|
492
|
+
INITIAL_STATE,
|
|
493
|
+
makeFormData({ ...UNTICKED, source }),
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
expect(result).toMatchObject({ kind: "error", fieldErrors: { consent: expect.any(String) } });
|
|
497
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
428
498
|
},
|
|
429
499
|
);
|
|
430
500
|
|
|
431
|
-
it("
|
|
501
|
+
it("consent other than the string true is not acceptance", async () => {
|
|
502
|
+
const result = await captureLeadAction(
|
|
503
|
+
INITIAL_STATE,
|
|
504
|
+
makeFormData({ ...VALID_FIELDS, consent: "on" }),
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
expect(result).toMatchObject({ kind: "error", fieldErrors: { consent: expect.any(String) } });
|
|
508
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("an intake post with consent=true is sent as conversational-intake, carrying consent: true", async () => {
|
|
432
512
|
mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
433
513
|
|
|
434
514
|
const result = await captureLeadAction(
|
|
435
515
|
INITIAL_STATE,
|
|
436
|
-
makeFormData({ ...
|
|
516
|
+
makeFormData({ ...VALID_FIELDS, source: "conversational-intake" }),
|
|
437
517
|
);
|
|
438
518
|
|
|
439
|
-
expect(result).toEqual({ kind: "success" });
|
|
440
|
-
|
|
441
|
-
|
|
519
|
+
expect(result).toEqual({ kind: "success", name: expect.any(String) });
|
|
520
|
+
const body = mockSubmit.mock.calls[0][0] as { source: string; consent: unknown };
|
|
521
|
+
expect(body.source).toBe("conversational-intake");
|
|
522
|
+
expect(body.consent).toBe(true);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("every accepted post tells the API the terms were accepted, top level, never in payload", async () => {
|
|
526
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
527
|
+
|
|
528
|
+
await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
529
|
+
|
|
530
|
+
const body = mockSubmit.mock.calls[0][0] as {
|
|
531
|
+
consent: unknown;
|
|
532
|
+
payload: Record<string, unknown>;
|
|
533
|
+
};
|
|
534
|
+
expect(body.consent).toBe(true);
|
|
535
|
+
expect(body.payload).not.toHaveProperty("consent");
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("an API 400 consent_required is shown as the consent field error", async () => {
|
|
539
|
+
mockSubmit.mockResolvedValueOnce({
|
|
540
|
+
ok: false,
|
|
541
|
+
status: 400,
|
|
542
|
+
body: { error: "consent_required", message: "Accept the terms to send a contact message." },
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
546
|
+
|
|
547
|
+
expect(result).toEqual({
|
|
548
|
+
kind: "error",
|
|
549
|
+
fieldErrors: { consent: expect.any(String) },
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// ─── #8747 — the spoke lead writer, the per-visitor limit, an unreachable API ─
|
|
555
|
+
|
|
556
|
+
describe("captureLeadAction — the site marker (#8747)", () => {
|
|
557
|
+
const priorSite = process.env.AILK_LEAD_SITE;
|
|
558
|
+
|
|
559
|
+
afterEach(() => {
|
|
560
|
+
if (priorSite === undefined) delete process.env.AILK_LEAD_SITE;
|
|
561
|
+
else process.env.AILK_LEAD_SITE = priorSite;
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("marks payload.site from AILK_LEAD_SITE and payload.page as contact, and sends no siteId", async () => {
|
|
565
|
+
process.env.AILK_LEAD_SITE = "wendihenry";
|
|
566
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
567
|
+
|
|
568
|
+
await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
569
|
+
|
|
570
|
+
const body = mockSubmit.mock.calls[0][0] as Record<string, unknown> & {
|
|
571
|
+
payload: Record<string, unknown>;
|
|
572
|
+
};
|
|
573
|
+
expect(body).not.toHaveProperty("siteId");
|
|
574
|
+
expect(body.payload).toMatchObject({ site: "wendihenry", page: "contact" });
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("a form field named site or page never overrides the marker", async () => {
|
|
578
|
+
process.env.AILK_LEAD_SITE = "wendihenry";
|
|
579
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
580
|
+
|
|
581
|
+
await captureLeadAction(
|
|
582
|
+
INITIAL_STATE,
|
|
583
|
+
makeFormData({ ...VALID_FIELDS, site: "attacker", page: "checkout" }),
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
const body = mockSubmit.mock.calls[0][0] as { payload: Record<string, unknown> };
|
|
587
|
+
expect(body.payload.site).toBe("wendihenry");
|
|
588
|
+
expect(body.payload.page).toBe("contact");
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("with AILK_LEAD_SITE unset, the lead carries no site at all", async () => {
|
|
592
|
+
delete process.env.AILK_LEAD_SITE;
|
|
593
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
594
|
+
|
|
595
|
+
await captureLeadAction(INITIAL_STATE, makeFormData({ ...VALID_FIELDS, site: "attacker" }));
|
|
596
|
+
|
|
597
|
+
const body = mockSubmit.mock.calls[0][0] as { payload: Record<string, unknown> };
|
|
598
|
+
expect(body.payload).not.toHaveProperty("site");
|
|
599
|
+
expect(body.payload.page).toBe("contact");
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe("captureLeadAction — the built-in form's source (#8747)", () => {
|
|
604
|
+
it("a post with no source is stamped contact-form", async () => {
|
|
605
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
606
|
+
|
|
607
|
+
await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
608
|
+
|
|
609
|
+
expect((mockSubmit.mock.calls[0][0] as { source: string }).source).toBe("contact-form");
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
describe("captureLeadAction — the per-visitor limit (#8747)", () => {
|
|
614
|
+
const visitor = (ip: string) => ({
|
|
615
|
+
get: (name: string) => (name === "x-forwarded-for" ? ip : null),
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("admits 30 posts from one visitor in ten minutes, and refuses the 31st without calling the API", async () => {
|
|
619
|
+
mockHeaders.mockResolvedValue(visitor("203.0.113.5"));
|
|
620
|
+
mockSubmit.mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
621
|
+
|
|
622
|
+
for (let i = 0; i < 30; i += 1) {
|
|
623
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
|
|
624
|
+
kind: "success",
|
|
625
|
+
name: expect.any(String),
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
expect(mockSubmit).toHaveBeenCalledTimes(30);
|
|
629
|
+
|
|
630
|
+
const refused = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
631
|
+
|
|
632
|
+
expect(refused).toMatchObject({ kind: "error", message: expect.any(String) });
|
|
633
|
+
expect(mockSubmit).toHaveBeenCalledTimes(30);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it("with no readable address, the limit is skipped rather than shared", async () => {
|
|
637
|
+
mockHeaders.mockResolvedValue({ get: () => null });
|
|
638
|
+
mockSubmit.mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
639
|
+
|
|
640
|
+
for (let i = 0; i < 31; i += 1) {
|
|
641
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
|
|
642
|
+
kind: "success",
|
|
643
|
+
name: expect.any(String),
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
expect(mockSubmit).toHaveBeenCalledTimes(31);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("another visitor keeps its own budget", async () => {
|
|
650
|
+
mockSubmit.mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
651
|
+
mockHeaders.mockResolvedValue(visitor("203.0.113.5"));
|
|
652
|
+
for (let i = 0; i < 30; i += 1) {
|
|
653
|
+
await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
mockHeaders.mockResolvedValue(visitor("198.51.100.7"));
|
|
657
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
|
|
658
|
+
kind: "success",
|
|
659
|
+
name: expect.any(String),
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
describe("captureLeadAction — an unreachable API (#8747)", () => {
|
|
665
|
+
it.each([502, 504])("a %s is unavailable", async (status) => {
|
|
666
|
+
mockSubmit.mockResolvedValueOnce({ ok: false, status, body: null });
|
|
667
|
+
|
|
668
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
|
|
669
|
+
kind: "unavailable",
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("a thrown submit is unavailable", async () => {
|
|
674
|
+
mockSubmit.mockRejectedValueOnce(new TypeError("fetch failed"));
|
|
675
|
+
const spy = jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
676
|
+
|
|
677
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
|
|
678
|
+
kind: "unavailable",
|
|
679
|
+
});
|
|
680
|
+
spy.mockRestore();
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it.each([400, 429, 500, 501, 503])("a %s that is not lead_capture_not_configured stays the generic error", async (status) => {
|
|
684
|
+
mockSubmit.mockResolvedValueOnce({ ok: false, status, body: { error: "x" } });
|
|
685
|
+
|
|
686
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
|
|
687
|
+
kind: "error",
|
|
688
|
+
message: expect.any(String),
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// ── #8757 AC-5 — the thank-you carries the account link on `invite` only ───
|
|
694
|
+
|
|
695
|
+
describe("captureLeadAction — the account link (#8757 D8)", () => {
|
|
696
|
+
const { getLocale } = jest.requireMock("next-intl/server") as { getLocale: jest.Mock };
|
|
697
|
+
const INVITE_FIELDS = { ...VALID_FIELDS, email: "a@b.example" };
|
|
698
|
+
const LABEL = "Track this in your account";
|
|
699
|
+
|
|
700
|
+
function answers(next?: unknown): void {
|
|
701
|
+
mockSubmit.mockResolvedValueOnce({
|
|
702
|
+
ok: true,
|
|
703
|
+
body: {
|
|
704
|
+
id: "lead_abc123",
|
|
705
|
+
persisted: "db",
|
|
706
|
+
email: { attempted: true, sent: true },
|
|
707
|
+
...(next !== undefined && { next }),
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
afterEach(() => {
|
|
713
|
+
mockSignInProvider = "site";
|
|
714
|
+
getLocale.mockResolvedValue("en");
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it("provider site, locale en (served bare): invite → the sign-in link with next and the email", async () => {
|
|
718
|
+
answers({ completion: "invite" });
|
|
719
|
+
|
|
720
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toEqual({
|
|
721
|
+
kind: "success",
|
|
722
|
+
name: expect.any(String),
|
|
723
|
+
next: { href: "/sign-in?next=%2Faccount&email=a%40b.example", label: LABEL },
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it("provider site, locale fr: both paths carry the prefix", async () => {
|
|
728
|
+
getLocale.mockResolvedValue("fr");
|
|
729
|
+
answers({ completion: "invite" });
|
|
730
|
+
|
|
731
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toEqual({
|
|
732
|
+
kind: "success",
|
|
733
|
+
name: expect.any(String),
|
|
734
|
+
next: { href: "/fr/sign-in?next=%2Ffr%2Faccount&email=a%40b.example", label: LABEL },
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("no next → exactly { kind: success }", async () => {
|
|
739
|
+
answers();
|
|
740
|
+
|
|
741
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toStrictEqual({
|
|
742
|
+
kind: "success",
|
|
743
|
+
name: expect.any(String),
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it("provider hub → the hub-session sign-in start", async () => {
|
|
748
|
+
mockSignInProvider = "hub";
|
|
749
|
+
answers({ completion: "invite" });
|
|
750
|
+
|
|
751
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toEqual({
|
|
752
|
+
kind: "success",
|
|
753
|
+
name: expect.any(String),
|
|
754
|
+
next: { href: "/api/hub-session/sign-in?next=%2Faccount&email=a%40b.example", label: LABEL },
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("provider none → exactly { kind: success }", async () => {
|
|
759
|
+
mockSignInProvider = "none";
|
|
760
|
+
answers({ completion: "invite" });
|
|
761
|
+
|
|
762
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toStrictEqual({
|
|
763
|
+
kind: "success",
|
|
764
|
+
name: expect.any(String),
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("an unreadable locale still answers success, with the default locale's link", async () => {
|
|
769
|
+
getLocale.mockRejectedValueOnce(new Error("no request scope"));
|
|
770
|
+
answers({ completion: "invite" });
|
|
771
|
+
|
|
772
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toEqual({
|
|
773
|
+
kind: "success",
|
|
774
|
+
name: expect.any(String),
|
|
775
|
+
next: { href: "/sign-in?next=%2Faccount&email=a%40b.example", label: LABEL },
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it("a malformed next is ignored → exactly { kind: success }", async () => {
|
|
780
|
+
answers({ completion: "invite", extra: true });
|
|
781
|
+
|
|
782
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(INVITE_FIELDS))).toStrictEqual({
|
|
783
|
+
kind: "success",
|
|
784
|
+
name: expect.any(String),
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
describe("captureLeadAction — a checkout magnet redirects after the write (#8757 D11, AC-8)", () => {
|
|
790
|
+
function answers(next: unknown): void {
|
|
791
|
+
mockSubmit.mockResolvedValueOnce({
|
|
792
|
+
ok: true,
|
|
793
|
+
body: { id: "lead_abc123", persisted: "db", email: { skipped: true }, next },
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
it("next: checkout, retainer → Next's redirect to /api/checkout/retainer, after exactly one write", async () => {
|
|
798
|
+
answers({ completion: "checkout", checkoutOffering: "retainer" });
|
|
799
|
+
|
|
800
|
+
await expect(captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).rejects.toThrow(
|
|
801
|
+
"NEXT_REDIRECT:/api/checkout/retainer",
|
|
802
|
+
);
|
|
803
|
+
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
804
|
+
expect(mockRedirect).toHaveBeenCalledTimes(1);
|
|
805
|
+
expect(mockRedirect).toHaveBeenCalledWith("/api/checkout/retainer");
|
|
806
|
+
expect(mockSubmit.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
807
|
+
mockRedirect.mock.invocationCallOrder[0]!,
|
|
808
|
+
);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it.each([
|
|
812
|
+
["a 500", { ok: false, status: 500, body: { error: "boom" } }, "error"],
|
|
813
|
+
["an unreachable API", { ok: false, status: 0, body: null }, "unavailable"],
|
|
814
|
+
[
|
|
815
|
+
"lead capture not configured",
|
|
816
|
+
{ ok: false, status: 503, body: { error: "lead_capture_not_configured" } },
|
|
817
|
+
"unavailable",
|
|
818
|
+
],
|
|
819
|
+
] as const)("a failed write (%s) → { kind: %s } and no redirect", async (_label, response, kind) => {
|
|
820
|
+
mockSubmit.mockResolvedValueOnce(response);
|
|
821
|
+
|
|
822
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
823
|
+
expect(result).toMatchObject({ kind });
|
|
824
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it("a thrown write → { kind: unavailable } and no redirect", async () => {
|
|
828
|
+
mockSubmit.mockRejectedValueOnce(new Error("network"));
|
|
829
|
+
|
|
830
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
831
|
+
expect(result).toMatchObject({ kind: "unavailable" });
|
|
832
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it.each(["../x", "/evil", "Retainer", "has_underscore", "a".repeat(41), ""])(
|
|
836
|
+
"checkoutOffering %j → exactly { kind: success } and no redirect",
|
|
837
|
+
async (checkoutOffering) => {
|
|
838
|
+
answers({ completion: "checkout", checkoutOffering });
|
|
839
|
+
|
|
840
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
|
|
841
|
+
kind: "success",
|
|
842
|
+
name: expect.any(String),
|
|
843
|
+
});
|
|
844
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
845
|
+
},
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
it("a build that is not a spoke (no standard checkout) → exactly { kind: success } and no redirect", async () => {
|
|
849
|
+
mockTopology = "hub";
|
|
850
|
+
try {
|
|
851
|
+
answers({ completion: "checkout", checkoutOffering: "retainer" });
|
|
852
|
+
|
|
853
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
|
|
854
|
+
kind: "success",
|
|
855
|
+
name: expect.any(String),
|
|
856
|
+
});
|
|
857
|
+
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
858
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
859
|
+
} finally {
|
|
860
|
+
mockTopology = "spoke";
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it("an invite still answers the account link and never redirects", async () => {
|
|
865
|
+
answers({ completion: "invite" });
|
|
866
|
+
|
|
867
|
+
const result = await captureLeadAction(
|
|
868
|
+
INITIAL_STATE,
|
|
869
|
+
makeFormData({ ...VALID_FIELDS, email: "a@b.example" }),
|
|
442
870
|
);
|
|
871
|
+
expect(result).toMatchObject({ kind: "success", next: { label: "Track this in your account" } });
|
|
872
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
443
873
|
});
|
|
444
874
|
});
|
|
@@ -173,8 +173,15 @@ async function renderWith(sections: unknown[] | null) {
|
|
|
173
173
|
return render(element);
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
// #8731 — the page shares its two hub reads for a few seconds, per module
|
|
177
|
+
// instance. Each test starts an hour later so none sees another's read.
|
|
178
|
+
let clock = 1_800_000_000_000;
|
|
179
|
+
const nowSpy = jest.spyOn(Date, "now");
|
|
180
|
+
|
|
176
181
|
beforeEach(() => {
|
|
177
182
|
jest.clearAllMocks();
|
|
183
|
+
clock += 60 * 60 * 1000;
|
|
184
|
+
nowSpy.mockImplementation(() => clock);
|
|
178
185
|
mockCaptureStatus.mockResolvedValue(ACCEPTING);
|
|
179
186
|
mockContactEmail.mockReturnValue(undefined);
|
|
180
187
|
});
|