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,69 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* #8771 AC-4 (the action) — accountInviteLinkAction builds #8757's link on the
|
|
4
|
+
* server and answers null when it cannot.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const mockGetLocale = jest.fn();
|
|
10
|
+
jest.mock("next-intl/server", () => ({
|
|
11
|
+
getLocale: (...args: unknown[]) => mockGetLocale(...args),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const mockInviteHref = jest.fn();
|
|
15
|
+
// eslint-disable-next-line no-restricted-syntax -- accountInviteHref reads the generated topology (build output); this file asserts the action's own decision
|
|
16
|
+
jest.mock("../../../../../lib/account-invite", () => ({
|
|
17
|
+
accountInviteHref: (...args: unknown[]) => mockInviteHref(...args),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { accountInviteLinkAction } from "../account-invite.actions";
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
jest.clearAllMocks();
|
|
24
|
+
mockGetLocale.mockResolvedValue("fr");
|
|
25
|
+
mockInviteHref.mockImplementation(
|
|
26
|
+
(locale: string, email: string) => `/${locale}/sign-in?email=${email}`,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("accountInviteLinkAction", () => {
|
|
31
|
+
it("returns what accountInviteHref returns for the request's locale", async () => {
|
|
32
|
+
await expect(accountInviteLinkAction("jane@example.com")).resolves.toBe(
|
|
33
|
+
"/fr/sign-in?email=jane@example.com",
|
|
34
|
+
);
|
|
35
|
+
expect(mockInviteHref).toHaveBeenCalledWith("fr", "jane@example.com");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it.each(["nope", "a\r\n@b.example", "a b@example.com"])(
|
|
39
|
+
"refuses %j",
|
|
40
|
+
async (email) => {
|
|
41
|
+
await expect(accountInviteLinkAction(email)).resolves.toBeNull();
|
|
42
|
+
expect(mockInviteHref).not.toHaveBeenCalled();
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
it("answers null when the locale cannot be read", async () => {
|
|
47
|
+
mockGetLocale.mockRejectedValue(new Error("no request scope"));
|
|
48
|
+
await expect(
|
|
49
|
+
accountInviteLinkAction("jane@example.com"),
|
|
50
|
+
).resolves.toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("answers null for a site with no sign-in", async () => {
|
|
54
|
+
mockInviteHref.mockReturnValue(null);
|
|
55
|
+
await expect(
|
|
56
|
+
accountInviteLinkAction("jane@example.com"),
|
|
57
|
+
).resolves.toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("makes no API call, and carries the oss marker on its first line", () => {
|
|
61
|
+
const source = readFileSync(
|
|
62
|
+
join(__dirname, "..", "account-invite.actions.ts"),
|
|
63
|
+
"utf8",
|
|
64
|
+
);
|
|
65
|
+
expect(source.split("\n")[0]).toBe("// oss: false");
|
|
66
|
+
expect(source).not.toMatch(/\bfetch\(/);
|
|
67
|
+
expect(source).not.toContain("@working-theory/api-client");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* #8840 D8 — the standard magnet page pair (`magnet-page.tsx`).
|
|
4
|
+
*
|
|
5
|
+
* AC-4 — the landing renders the flow record's `landing` copy (one H1, the
|
|
6
|
+
* line, the meta line, a CTA to `<path>/start`) with the `Assessment`
|
|
7
|
+
* page type's `WebApplication` JSON-LD and no robots key, falling back
|
|
8
|
+
* to step 1's title and the generic `flow` copy; the walk renders the
|
|
9
|
+
* back arrow and the stepper on step 1 and is `noindex, follow`.
|
|
10
|
+
* AC-5 — an unreachable hub (a rejected read or a 5xx) shows the site's
|
|
11
|
+
* contact email on both pages, both `noindex`, and nothing throws; a
|
|
12
|
+
* 404 is `notFound()`; one slug's read is shared for 15 seconds, and a
|
|
13
|
+
* slug the schema refuses is never read.
|
|
14
|
+
*/
|
|
15
|
+
import { render, screen } from "@testing-library/react";
|
|
16
|
+
import type { FlowConfig } from "@working-theory/validation";
|
|
17
|
+
import type { ReactElement } from "react";
|
|
18
|
+
|
|
19
|
+
const mockGet = jest.fn();
|
|
20
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
21
|
+
__esModule: true,
|
|
22
|
+
flows: { get: (...args: unknown[]) => mockGet(...args) },
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const mockRedirect = jest.fn((href: string) => {
|
|
26
|
+
throw new Error(`NEXT_REDIRECT ${href}`);
|
|
27
|
+
});
|
|
28
|
+
jest.mock("next/navigation", () => ({
|
|
29
|
+
__esModule: true,
|
|
30
|
+
notFound: () => {
|
|
31
|
+
throw new Error("NEXT_NOT_FOUND");
|
|
32
|
+
},
|
|
33
|
+
redirect: (href: string) => mockRedirect(href),
|
|
34
|
+
useRouter: () => ({ push: jest.fn() }),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
jest.mock("next-intl/server", () => ({
|
|
38
|
+
__esModule: true,
|
|
39
|
+
getTranslations: async () => (key: string) => key,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
// The hero section's data, drawn as plain landmarks the assertions read.
|
|
43
|
+
jest.mock("@working-theory/ui/sections", () => {
|
|
44
|
+
const react = require("react");
|
|
45
|
+
return {
|
|
46
|
+
__esModule: true,
|
|
47
|
+
SectionRenderer: ({ sections }: { sections: Array<{ data: Record<string, unknown> }> }) => {
|
|
48
|
+
const data = sections[0]!.data as {
|
|
49
|
+
headline: string;
|
|
50
|
+
subheadline: string;
|
|
51
|
+
body?: string;
|
|
52
|
+
variant?: string;
|
|
53
|
+
primaryCta: { label: string; href: string };
|
|
54
|
+
};
|
|
55
|
+
return react.createElement(
|
|
56
|
+
"section",
|
|
57
|
+
{ "data-variant": data.variant },
|
|
58
|
+
react.createElement("h1", null, data.headline),
|
|
59
|
+
react.createElement("p", { "data-testid": "hero-line" }, data.subheadline),
|
|
60
|
+
data.body !== undefined
|
|
61
|
+
? react.createElement("p", { "data-testid": "hero-meta" }, data.body)
|
|
62
|
+
: null,
|
|
63
|
+
react.createElement("a", { href: data.primaryCta.href, "data-testid": "hero-cta" }, data.primaryCta.label),
|
|
64
|
+
);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const mockStepper = jest.fn();
|
|
70
|
+
// eslint-disable-next-line no-restricted-syntax -- the walk's client wiring has its own suites; this file pins the renderer
|
|
71
|
+
jest.mock("../FlowStepperClient", () => ({
|
|
72
|
+
FlowStepperClient: (props: Record<string, unknown>) => {
|
|
73
|
+
mockStepper(props);
|
|
74
|
+
const react = require("react");
|
|
75
|
+
return react.createElement(
|
|
76
|
+
"div",
|
|
77
|
+
{ "data-testid": "stepper" },
|
|
78
|
+
props["header"] as ReactElement,
|
|
79
|
+
(props["config"] as FlowConfig).steps[0]!.copy.title["en"],
|
|
80
|
+
);
|
|
81
|
+
},
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
// eslint-disable-next-line no-restricted-syntax -- the contact email is env-backed; its reader has its own suite
|
|
85
|
+
jest.mock("../../../contact/site-contact-email", () => ({
|
|
86
|
+
readSiteContactEmail: () => "hello@example.com",
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
import {
|
|
90
|
+
magnetLandingMetadata,
|
|
91
|
+
magnetStartMetadata,
|
|
92
|
+
readMagnet,
|
|
93
|
+
renderMagnetLanding,
|
|
94
|
+
renderMagnetStart,
|
|
95
|
+
renderMagnetStartMetadata,
|
|
96
|
+
} from "../magnet-page";
|
|
97
|
+
|
|
98
|
+
const en = (s: string) => ({ en: s });
|
|
99
|
+
|
|
100
|
+
function config(withLanding = true): FlowConfig {
|
|
101
|
+
return {
|
|
102
|
+
slug: "growth",
|
|
103
|
+
defaultLocale: "en",
|
|
104
|
+
...(withLanding
|
|
105
|
+
? {
|
|
106
|
+
landing: {
|
|
107
|
+
title: en("How ready is your team?"),
|
|
108
|
+
description: en("Ten questions, one page of results."),
|
|
109
|
+
meta: en("About 3 minutes · Your result arrives by email"),
|
|
110
|
+
cta: en("Start the diagnostic"),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
: {}),
|
|
114
|
+
steps: [
|
|
115
|
+
{
|
|
116
|
+
key: "team",
|
|
117
|
+
type: "field",
|
|
118
|
+
copy: { title: en("Who are you?") },
|
|
119
|
+
fields: [{ name: "email", inputType: "email", required: true, mapsTo: "email", copy: { label: en("Email") } }],
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
} as FlowConfig;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// One slug's read is shared for 15 seconds; each test starts an hour later.
|
|
126
|
+
let clock = 1_800_000_000_000;
|
|
127
|
+
jest.spyOn(Date, "now").mockImplementation(() => clock);
|
|
128
|
+
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
clock += 60 * 60 * 1000;
|
|
131
|
+
mockGet.mockReset();
|
|
132
|
+
mockStepper.mockReset();
|
|
133
|
+
mockGet.mockResolvedValue({ ok: true, body: config() });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const params = Promise.resolve({ locale: "en" });
|
|
137
|
+
const REF = { slug: "growth", path: "/diagnostic" };
|
|
138
|
+
|
|
139
|
+
describe("the landing (#8840 D8, AC-4)", () => {
|
|
140
|
+
it("renders the record's landing copy, a CTA to <path>/start and the WebApplication JSON-LD", async () => {
|
|
141
|
+
const { container } = render(
|
|
142
|
+
await renderMagnetLanding({ slug: "growth", path: "/diagnostic", locale: "en" }),
|
|
143
|
+
);
|
|
144
|
+
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
|
|
145
|
+
expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("How ready is your team?");
|
|
146
|
+
expect(screen.getByTestId("hero-line").textContent).toBe("Ten questions, one page of results.");
|
|
147
|
+
expect(screen.getByTestId("hero-meta").textContent).toBe(
|
|
148
|
+
"About 3 minutes · Your result arrives by email",
|
|
149
|
+
);
|
|
150
|
+
expect(screen.getByTestId("hero-cta").getAttribute("href")).toBe("/diagnostic/start");
|
|
151
|
+
expect(screen.getByTestId("hero-cta").textContent).toBe("Start the diagnostic");
|
|
152
|
+
expect(container.querySelector("section")?.getAttribute("data-variant")).toBe("centered");
|
|
153
|
+
|
|
154
|
+
const script = container.querySelector('script[type="application/ld+json"]');
|
|
155
|
+
const jsonLd = JSON.parse(script!.textContent!) as { "@type": string; name: string };
|
|
156
|
+
expect(jsonLd["@type"]).toBe("WebApplication");
|
|
157
|
+
expect(jsonLd.name).toBe("How ready is your team?");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("localizes the CTA for a non-default locale", async () => {
|
|
161
|
+
render(await renderMagnetLanding({ slug: "growth", path: "/diagnostic", locale: "fr" }));
|
|
162
|
+
expect(screen.getByTestId("hero-cta").getAttribute("href")).toBe("/fr/diagnostic/start");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("falls back to step 1's title, flow.landingIntro and flow.getStarted, with no meta line", async () => {
|
|
166
|
+
mockGet.mockResolvedValue({ ok: true, body: config(false) });
|
|
167
|
+
render(await renderMagnetLanding({ slug: "growth", path: "/diagnostic", locale: "en" }));
|
|
168
|
+
expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("Who are you?");
|
|
169
|
+
expect(screen.getByTestId("hero-line").textContent).toBe("landingIntro");
|
|
170
|
+
expect(screen.getByTestId("hero-cta").textContent).toBe("getStarted");
|
|
171
|
+
expect(screen.queryByTestId("hero-meta")).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("is indexable: the metadata carries the title and no robots key", async () => {
|
|
175
|
+
const metadata = await magnetLandingMetadata(REF)({ params });
|
|
176
|
+
expect(metadata.robots).toBeUndefined();
|
|
177
|
+
expect(metadata.title).toBe("How ready is your team?");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("the walk (#8840 D8, AC-4)", () => {
|
|
182
|
+
it("renders a NavBackLink to the landing and the stepper on step 1", async () => {
|
|
183
|
+
render(await renderMagnetStart({ slug: "growth", locale: "en", backHref: "/diagnostic" }));
|
|
184
|
+
expect(screen.getByTestId("nav-back-link").getAttribute("href")).toBe("/diagnostic");
|
|
185
|
+
expect(screen.getByTestId("stepper").textContent).toContain("Who are you?");
|
|
186
|
+
const props = mockStepper.mock.calls[0]![0] as Record<string, unknown>;
|
|
187
|
+
expect(props["slug"]).toBe("growth");
|
|
188
|
+
expect(props).not.toHaveProperty("hiddenValues");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("replaces step 1's title and passes hidden values when asked", async () => {
|
|
192
|
+
render(
|
|
193
|
+
await renderMagnetStart({
|
|
194
|
+
slug: "growth",
|
|
195
|
+
locale: "en",
|
|
196
|
+
backHref: "/careers/writer",
|
|
197
|
+
hiddenValues: { job: "writer" },
|
|
198
|
+
firstStepTitle: "Writer",
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
const props = mockStepper.mock.calls[0]![0] as { config: FlowConfig; hiddenValues: unknown };
|
|
202
|
+
expect(props.config.steps[0]!.copy.title).toEqual({ en: "Writer" });
|
|
203
|
+
expect(props.hiddenValues).toEqual({ job: "writer" });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("is noindex, follow", async () => {
|
|
207
|
+
const metadata = await magnetStartMetadata(REF)({ params });
|
|
208
|
+
expect(metadata.robots).toEqual({ index: false, follow: true });
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("an unreachable hub shows the contact email (#8840 D8, AC-5)", () => {
|
|
213
|
+
// #9219 — a configured hub, so a rejected read or a plain 503 is an outage
|
|
214
|
+
// (`unavailable`), not a site with no hub to ask.
|
|
215
|
+
let savedOrigin: string | undefined;
|
|
216
|
+
beforeEach(() => {
|
|
217
|
+
savedOrigin = process.env.AILK_API_URL;
|
|
218
|
+
process.env.AILK_API_URL = "https://hub.example";
|
|
219
|
+
});
|
|
220
|
+
afterEach(() => {
|
|
221
|
+
if (savedOrigin === undefined) delete process.env.AILK_API_URL;
|
|
222
|
+
else process.env.AILK_API_URL = savedOrigin;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it.each([
|
|
226
|
+
["a rejected read", () => mockGet.mockRejectedValue(new TypeError("fetch failed"))],
|
|
227
|
+
["a 503", () => mockGet.mockResolvedValue({ ok: false, status: 503, body: null })],
|
|
228
|
+
// #9219 — a hub that is not configured is its own read kind, and a magnet
|
|
229
|
+
// pair treats it exactly as an outage.
|
|
230
|
+
[
|
|
231
|
+
"a 503 flows_not_configured",
|
|
232
|
+
() =>
|
|
233
|
+
mockGet.mockResolvedValue({
|
|
234
|
+
ok: false,
|
|
235
|
+
status: 503,
|
|
236
|
+
body: { ok: false, error: { code: "flows_not_configured", message: "x" } },
|
|
237
|
+
}),
|
|
238
|
+
],
|
|
239
|
+
])("%s: both pages render ContactFallback and both are noindex", async (_label, arrange) => {
|
|
240
|
+
arrange();
|
|
241
|
+
const landing = render(
|
|
242
|
+
await renderMagnetLanding({ slug: "growth", path: "/diagnostic", locale: "en" }),
|
|
243
|
+
);
|
|
244
|
+
expect(screen.getByTestId("contact-fallback").textContent).toContain("hello@example.com");
|
|
245
|
+
expect(screen.getByTestId("contact-fallback").textContent).toContain("unavailableHeading");
|
|
246
|
+
landing.unmount();
|
|
247
|
+
|
|
248
|
+
render(await renderMagnetStart({ slug: "growth", locale: "en", backHref: "/diagnostic" }));
|
|
249
|
+
expect(screen.getByTestId("contact-fallback").textContent).toContain("hello@example.com");
|
|
250
|
+
expect(screen.getByTestId("nav-back-link")).toBeTruthy();
|
|
251
|
+
expect(mockStepper).not.toHaveBeenCalled();
|
|
252
|
+
|
|
253
|
+
expect((await magnetLandingMetadata(REF)({ params })).robots).toEqual({ index: false, follow: true });
|
|
254
|
+
expect((await magnetStartMetadata(REF)({ params })).robots).toEqual({ index: false, follow: true });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("a 404 is notFound() on both pages, not an outage", async () => {
|
|
258
|
+
mockGet.mockResolvedValue({ ok: false, status: 404, body: null });
|
|
259
|
+
await expect(
|
|
260
|
+
renderMagnetLanding({ slug: "growth", path: "/diagnostic", locale: "en" }),
|
|
261
|
+
).rejects.toThrow("NEXT_NOT_FOUND");
|
|
262
|
+
await expect(
|
|
263
|
+
renderMagnetStart({ slug: "growth", locale: "en", backHref: "/diagnostic" }),
|
|
264
|
+
).rejects.toThrow("NEXT_NOT_FOUND");
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("a walk that exists apart from its flow passes a title (#9219)", () => {
|
|
269
|
+
const titled = { slug: "growth", locale: "en", backHref: "/careers", title: "Job application" };
|
|
270
|
+
|
|
271
|
+
it.each([
|
|
272
|
+
["a 404", () => mockGet.mockResolvedValue({ ok: false, status: 404, body: null })],
|
|
273
|
+
["a rejected read", () => mockGet.mockRejectedValue(new TypeError("fetch failed"))],
|
|
274
|
+
["a 503", () => mockGet.mockResolvedValue({ ok: false, status: 503, body: null })],
|
|
275
|
+
])("%s: the contact email under the page's own <h1>, never a 404", async (_label, arrange) => {
|
|
276
|
+
arrange();
|
|
277
|
+
render(await renderMagnetStart(titled));
|
|
278
|
+
expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("Job application");
|
|
279
|
+
expect(screen.getByTestId("contact-fallback").textContent).toContain("hello@example.com");
|
|
280
|
+
expect(screen.getByTestId("nav-back-link").getAttribute("href")).toBe("/careers");
|
|
281
|
+
expect(mockStepper).not.toHaveBeenCalled();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("the fallback's metadata carries the title and stays noindex", async () => {
|
|
285
|
+
mockGet.mockRejectedValue(new TypeError("fetch failed"));
|
|
286
|
+
const metadata = await renderMagnetStartMetadata({ slug: "growth", locale: "en", title: "Job application" });
|
|
287
|
+
expect(metadata).toEqual({ title: "Job application", robots: { index: false, follow: true } });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("a served flow still walks, with no extra heading", async () => {
|
|
291
|
+
render(await renderMagnetStart(titled));
|
|
292
|
+
expect(mockStepper).toHaveBeenCalledTimes(1);
|
|
293
|
+
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("without a title an unreachable hub renders no <h1> of its own, and a 404 stays notFound()", async () => {
|
|
297
|
+
mockGet.mockRejectedValue(new TypeError("fetch failed"));
|
|
298
|
+
render(await renderMagnetStart({ slug: "growth", locale: "en", backHref: "/diagnostic" }));
|
|
299
|
+
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
|
|
300
|
+
|
|
301
|
+
clock += 60 * 60 * 1000;
|
|
302
|
+
mockGet.mockResolvedValue({ ok: false, status: 404, body: null });
|
|
303
|
+
await expect(
|
|
304
|
+
renderMagnetStart({ slug: "growth", locale: "en", backHref: "/diagnostic" }),
|
|
305
|
+
).rejects.toThrow("NEXT_NOT_FOUND");
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("the shared read (#8840 D8, AC-5)", () => {
|
|
310
|
+
it("two calls within 15 seconds make one flows.get call", async () => {
|
|
311
|
+
await readMagnet("growth");
|
|
312
|
+
clock += 14_000;
|
|
313
|
+
await readMagnet("growth");
|
|
314
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
315
|
+
clock += 2_000;
|
|
316
|
+
await readMagnet("growth");
|
|
317
|
+
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("holds at most 256 slugs: past the cap a read is made but not shared", async () => {
|
|
321
|
+
for (let i = 0; i < 300; i += 1) await readMagnet(`slug-${i}`);
|
|
322
|
+
expect(mockGet).toHaveBeenCalledTimes(300);
|
|
323
|
+
await readMagnet("slug-0");
|
|
324
|
+
expect(mockGet).toHaveBeenCalledTimes(300);
|
|
325
|
+
await readMagnet("slug-299");
|
|
326
|
+
expect(mockGet).toHaveBeenCalledTimes(301);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("a slug the schema refuses is absent with no read", async () => {
|
|
330
|
+
await expect(readMagnet("Bad_Slug")).resolves.toEqual({ kind: "absent" });
|
|
331
|
+
await expect(readMagnet("Bad_Slug")).resolves.toEqual({ kind: "absent" });
|
|
332
|
+
expect(mockGet).not.toHaveBeenCalled();
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("the read tells a hub that is not configured from one that is down (#9219)", () => {
|
|
337
|
+
const ORIGIN_NAMES = ["AILK_API_URL", "NEXT_PUBLIC_AILK_API_URL", "NEXT_PUBLIC_API_URL"] as const;
|
|
338
|
+
const saved: Record<string, string | undefined> = {};
|
|
339
|
+
beforeEach(() => {
|
|
340
|
+
for (const name of ORIGIN_NAMES) {
|
|
341
|
+
saved[name] = process.env[name];
|
|
342
|
+
delete process.env[name];
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
afterEach(() => {
|
|
346
|
+
for (const name of ORIGIN_NAMES) {
|
|
347
|
+
if (saved[name] === undefined) delete process.env[name];
|
|
348
|
+
else process.env[name] = saved[name];
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const notConfigured503 = {
|
|
353
|
+
ok: false,
|
|
354
|
+
status: 503,
|
|
355
|
+
body: { ok: false, error: { code: "flows_not_configured", message: "x" } },
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
it("a 503 flows_not_configured is not-configured, origin set or not", async () => {
|
|
359
|
+
process.env.AILK_API_URL = "https://hub.example";
|
|
360
|
+
mockGet.mockResolvedValue(notConfigured503);
|
|
361
|
+
await expect(readMagnet("growth")).resolves.toEqual({ kind: "not-configured" });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it.each(ORIGIN_NAMES)("with %s set, a 5xx or a throw is unavailable", async (name) => {
|
|
365
|
+
process.env[name] = "https://hub.example";
|
|
366
|
+
mockGet.mockResolvedValue({ ok: false, status: 503, body: null });
|
|
367
|
+
await expect(readMagnet("growth")).resolves.toEqual({ kind: "unavailable" });
|
|
368
|
+
clock += 60_000;
|
|
369
|
+
mockGet.mockRejectedValue(new TypeError("fetch failed"));
|
|
370
|
+
await expect(readMagnet("growth")).resolves.toEqual({ kind: "unavailable" });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("with no API origin, a 5xx or a throw is not-configured", async () => {
|
|
374
|
+
mockGet.mockResolvedValue({ ok: false, status: 502, body: null });
|
|
375
|
+
await expect(readMagnet("growth")).resolves.toEqual({ kind: "not-configured" });
|
|
376
|
+
clock += 60_000;
|
|
377
|
+
mockGet.mockRejectedValue(new TypeError("fetch failed"));
|
|
378
|
+
await expect(readMagnet("growth")).resolves.toEqual({ kind: "not-configured" });
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("with no API origin, a 404 is still absent and a served flow still ok", async () => {
|
|
382
|
+
mockGet.mockResolvedValue({ ok: false, status: 404, body: null });
|
|
383
|
+
await expect(readMagnet("growth")).resolves.toEqual({ kind: "absent" });
|
|
384
|
+
clock += 60_000;
|
|
385
|
+
mockGet.mockResolvedValue({ ok: true, body: config() });
|
|
386
|
+
await expect(readMagnet("growth")).resolves.toMatchObject({ kind: "ok" });
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe("a deliverable flow leaves the pair for /reports (#8848 D7, AC-4)", () => {
|
|
391
|
+
function deliverableConfig(): FlowConfig {
|
|
392
|
+
// As served: a gated block's target is projected out (#8848 D2).
|
|
393
|
+
const served = { ...config(), slug: "q3", deliverable: { kind: "page", gated: true, title: en("Q3 report") } };
|
|
394
|
+
// eslint-disable-next-line no-restricted-syntax -- a served gated block omits its target, which FlowConfig cannot express
|
|
395
|
+
return served as unknown as FlowConfig;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
beforeEach(() => {
|
|
399
|
+
mockRedirect.mockClear();
|
|
400
|
+
mockGet.mockResolvedValue({ ok: true, body: deliverableConfig() });
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("renderMagnetLanding redirects to /reports/<slug>", async () => {
|
|
404
|
+
await expect(renderMagnetLanding({ slug: "q3", path: "/flows/q3", locale: "en" })).rejects.toThrow(
|
|
405
|
+
"NEXT_REDIRECT /reports/q3",
|
|
406
|
+
);
|
|
407
|
+
await expect(renderMagnetLanding({ slug: "q3", path: "/flows/q3", locale: "fr" })).rejects.toThrow(
|
|
408
|
+
"NEXT_REDIRECT /fr/reports/q3",
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("renderMagnetStart redirects to /reports/<slug>/start", async () => {
|
|
413
|
+
await expect(renderMagnetStart({ slug: "q3", locale: "en", backHref: "/flows/q3" })).rejects.toThrow(
|
|
414
|
+
"NEXT_REDIRECT /reports/q3/start",
|
|
415
|
+
);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("renderMagnetStart with asDeliverable renders the walk", async () => {
|
|
419
|
+
render(await renderMagnetStart({ slug: "q3", locale: "en", backHref: "/reports/q3", asDeliverable: true }));
|
|
420
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
421
|
+
expect(screen.getByTestId("nav-back-link").getAttribute("href")).toBe("/reports/q3");
|
|
422
|
+
expect(screen.getByTestId("stepper")).toBeTruthy();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("a config with no block renders as today", async () => {
|
|
426
|
+
mockGet.mockResolvedValue({ ok: true, body: config() });
|
|
427
|
+
render(await renderMagnetStart({ slug: "growth", locale: "en", backHref: "/diagnostic" }));
|
|
428
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
429
|
+
});
|
|
430
|
+
});
|
|
@@ -76,7 +76,14 @@ function fixtureConfig(): FlowConfig {
|
|
|
76
76
|
} as FlowConfig;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// #8840 D8 — both routes render through `magnet-page.tsx`, which shares one
|
|
80
|
+
// slug's read for 15 seconds per instance; each test starts an hour later so
|
|
81
|
+
// none sees another's read.
|
|
82
|
+
let clock = 1_800_000_000_000;
|
|
83
|
+
jest.spyOn(Date, "now").mockImplementation(() => clock);
|
|
84
|
+
|
|
79
85
|
beforeEach(() => {
|
|
86
|
+
clock += 60 * 60 * 1000;
|
|
80
87
|
mockGet.mockReset();
|
|
81
88
|
mockGet.mockResolvedValue({ ok: true, body: fixtureConfig() });
|
|
82
89
|
});
|
|
@@ -95,16 +102,21 @@ describe("the flow route", () => {
|
|
|
95
102
|
expect(metadata.robots).toEqual({ index: false, follow: true });
|
|
96
103
|
});
|
|
97
104
|
|
|
105
|
+
// #8840 D8 — the route mounts the standard renderer, which mounts
|
|
106
|
+
// `flowRouteMetadata`; neither writes the robots value itself.
|
|
98
107
|
it("gets the value from flowRouteMetadata rather than writing it", () => {
|
|
99
|
-
const
|
|
100
|
-
join(__dirname, "..",
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
expect(
|
|
106
|
-
expect(
|
|
107
|
-
|
|
108
|
+
const strip = (file: string) =>
|
|
109
|
+
readFileSync(join(__dirname, "..", file), "utf8")
|
|
110
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
111
|
+
.replace(/\/\/.*$/gm, "");
|
|
112
|
+
const route = strip(join("start", "page.tsx"));
|
|
113
|
+
const renderer = strip("magnet-page.tsx");
|
|
114
|
+
expect(route).toContain("renderMagnetStartMetadata");
|
|
115
|
+
expect(renderer).toContain("flowRouteMetadata");
|
|
116
|
+
for (const code of [route, renderer]) {
|
|
117
|
+
expect(code).not.toMatch(/robots/i);
|
|
118
|
+
expect(code).not.toMatch(/noindex/i);
|
|
119
|
+
}
|
|
108
120
|
});
|
|
109
121
|
});
|
|
110
122
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
"use server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* accountInviteLinkAction — the walk's "Track this in your account" link
|
|
6
|
+
* (#8771 D5). The walk's client holds the email the visitor typed; this builds
|
|
7
|
+
* the sign-in link that starts with that email prefilled, using #8757's
|
|
8
|
+
* `accountInviteHref`. It is a Server Action, not client code, because
|
|
9
|
+
* `accountInviteHref` reads the generated topology module, which lazily
|
|
10
|
+
* imports `../auth` and must never reach a client bundle.
|
|
11
|
+
*
|
|
12
|
+
* It makes no API call and writes nothing — the invite email is sent by the
|
|
13
|
+
* API at completion. No MCP twin: it builds a link from the visitor's own
|
|
14
|
+
* email for a browser to follow (`WEB_ONLY_ACTIONS` in
|
|
15
|
+
* `apps/mcp/__tests__/parity.test.ts`).
|
|
16
|
+
*
|
|
17
|
+
* Answers `null` for an email the hint schema refuses, a locale that cannot be
|
|
18
|
+
* read, or a site with no sign-in — the walk then shows no link.
|
|
19
|
+
*/
|
|
20
|
+
import { accountEmailHintSchema } from "@working-theory/validation";
|
|
21
|
+
import { getLocale } from "next-intl/server";
|
|
22
|
+
|
|
23
|
+
import { accountInviteHref } from "../../../../lib/account-invite";
|
|
24
|
+
|
|
25
|
+
export async function accountInviteLinkAction(
|
|
26
|
+
email: string,
|
|
27
|
+
): Promise<string | null> {
|
|
28
|
+
const hint = accountEmailHintSchema.safeParse(email);
|
|
29
|
+
if (!hint.success) return null;
|
|
30
|
+
try {
|
|
31
|
+
return accountInviteHref(await getLocale(), hint.data);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|