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,352 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* magnet-page.tsx — the standard magnet page pair (#8840 D8).
|
|
4
|
+
*
|
|
5
|
+
* Every multi-step magnet is two pages, the canon's #5053 split moved to the
|
|
6
|
+
* magnet's own path: an indexable LANDING (`/<magnet>`, inside the site's
|
|
7
|
+
* chrome — a centred hero of the flow record's `landing` copy, a CTA to
|
|
8
|
+
* `/<magnet>/start`, and the `Assessment` page type's single-layer
|
|
9
|
+
* `WebApplication` JSON-LD) and a `noindex, follow` WALK (`/<magnet>/start`,
|
|
10
|
+
* chrome-free by `lib/magnet-routes.ts`, the back arrow alone above step 1).
|
|
11
|
+
* This module is the only code that renders either half: `/flows/<slug>`,
|
|
12
|
+
* the careers apply pages and every pair `create-ailk add-route magnet`
|
|
13
|
+
* writes are callers of the factories below.
|
|
14
|
+
*
|
|
15
|
+
* The read: one `flows.get(slug)` shared per server instance for 15 seconds
|
|
16
|
+
* per slug (the /contact idiom, #8731), so a page's metadata, its body and a
|
|
17
|
+
* burst of views make one hub read. A slug the schema refuses is `absent`
|
|
18
|
+
* with no read and no cache entry, each insert first drops the stale
|
|
19
|
+
* entries, and the map holds at most 256 slugs, so random URLs cannot grow
|
|
20
|
+
* it (SEC-8840-G). A refused
|
|
21
|
+
* connection or a 5xx is `unavailable`: both pages then show the site's
|
|
22
|
+
* contact email instead of a 500, are `noindex`, and retry nothing. A site
|
|
23
|
+
* with no hub to ask — the read answers 503 `flows_not_configured`, or a
|
|
24
|
+
* failed read with no API origin configured — is `not-configured` (#9219);
|
|
25
|
+
* a pair renders it as `unavailable`, and `/reports/<slug>` uses it to 404
|
|
26
|
+
* an unknown slug where a hub outage shows the contact email. A walk
|
|
27
|
+
* that exists apart from its flow (`/careers/apply`) passes a `title`: its
|
|
28
|
+
* fallback then carries that page heading, and an absent flow renders the
|
|
29
|
+
* same fallback rather than a 404 (#9219).
|
|
30
|
+
*
|
|
31
|
+
* A flow whose served config carries a `deliverable` (#8848 D7) has one
|
|
32
|
+
* route, `/reports/<slug>`: both halves redirect there (307) before
|
|
33
|
+
* rendering anything, so `/flows/<slug>` and every `add-route magnet` pair
|
|
34
|
+
* hand it over and an ungated deliverable never gets a form here. The
|
|
35
|
+
* deliverable's own walk calls `renderMagnetStart` with `asDeliverable`.
|
|
36
|
+
*/
|
|
37
|
+
import { flows } from "@working-theory/api-client";
|
|
38
|
+
import { JsonLd } from "@working-theory/jsonld";
|
|
39
|
+
import { flowRouteMetadata } from "@working-theory/metadata";
|
|
40
|
+
import { buildJsonLd } from "@working-theory/schema";
|
|
41
|
+
import { ContactFallback, Container, Heading, localeHref } from "@working-theory/ui";
|
|
42
|
+
import { NavBackLink } from "@working-theory/ui/chrome";
|
|
43
|
+
import { SectionRenderer } from "@working-theory/ui/sections";
|
|
44
|
+
import type { FlowConfig, PageSection } from "@working-theory/validation";
|
|
45
|
+
import { flowSlugSchema, resolveLocalizedString } from "@working-theory/validation";
|
|
46
|
+
import type { Metadata } from "next";
|
|
47
|
+
import { notFound, redirect } from "next/navigation";
|
|
48
|
+
import { getTranslations } from "next-intl/server";
|
|
49
|
+
import type { ReactElement } from "react";
|
|
50
|
+
|
|
51
|
+
import { readSiteContactEmail } from "../../contact/site-contact-email";
|
|
52
|
+
|
|
53
|
+
import { FlowStepperClient } from "./FlowStepperClient";
|
|
54
|
+
|
|
55
|
+
/** The marker every standard magnet page carries in its header comment (D13). */
|
|
56
|
+
export const MAGNET_PAGE_MARKER = "Standard magnet page (#8840)";
|
|
57
|
+
|
|
58
|
+
/** How long one slug's read is shared across requests (/contact's #8731 value). */
|
|
59
|
+
const MAGNET_READ_TTL_MS = 15_000;
|
|
60
|
+
|
|
61
|
+
/** At most this many slugs are held at once; past it a read is not shared. */
|
|
62
|
+
const MAGNET_READ_MAX_ENTRIES = 256;
|
|
63
|
+
|
|
64
|
+
export type MagnetRead =
|
|
65
|
+
| { kind: "ok"; config: FlowConfig }
|
|
66
|
+
| { kind: "absent" }
|
|
67
|
+
| { kind: "unavailable" }
|
|
68
|
+
| { kind: "not-configured" };
|
|
69
|
+
|
|
70
|
+
const magnetReads = new Map<string, { at: number; read: Promise<MagnetRead> }>();
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether an API origin is configured: the three names `getApiBase()` in
|
|
74
|
+
* packages/api-client/src/http.ts reads, in step with it (as `/pricing`
|
|
75
|
+
* checks them). Without one that function answers `localhost:3001`.
|
|
76
|
+
*/
|
|
77
|
+
function apiOriginConfigured(): boolean {
|
|
78
|
+
return Boolean(
|
|
79
|
+
process.env.AILK_API_URL ||
|
|
80
|
+
process.env.NEXT_PUBLIC_AILK_API_URL ||
|
|
81
|
+
process.env.NEXT_PUBLIC_API_URL,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The flows route's own answer when its hub has no database (apps/api `flows_not_configured`). */
|
|
86
|
+
function isFlowsNotConfigured(body: unknown): boolean {
|
|
87
|
+
const error = (body as { error?: { code?: unknown } } | null)?.error;
|
|
88
|
+
return error?.code === "flows_not_configured";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A read that failed: `not-configured` when there is no hub to ask, else `unavailable`. */
|
|
92
|
+
function failedRead(): MagnetRead {
|
|
93
|
+
return apiOriginConfigured() ? { kind: "unavailable" } : { kind: "not-configured" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function fetchMagnet(slug: string): Promise<MagnetRead> {
|
|
97
|
+
try {
|
|
98
|
+
const res = await flows.get(slug);
|
|
99
|
+
if (res.ok) return { kind: "ok", config: res.body };
|
|
100
|
+
if (res.status === 503 && isFlowsNotConfigured(res.body)) return { kind: "not-configured" };
|
|
101
|
+
return res.status >= 500 ? failedRead() : { kind: "absent" };
|
|
102
|
+
} catch {
|
|
103
|
+
return failedRead();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The served flow config for `slug`, shared per instance for 15 seconds. */
|
|
108
|
+
export function readMagnet(slug: string): Promise<MagnetRead> {
|
|
109
|
+
if (!flowSlugSchema.safeParse(slug).success) {
|
|
110
|
+
return Promise.resolve({ kind: "absent" });
|
|
111
|
+
}
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const hit = magnetReads.get(slug);
|
|
114
|
+
if (hit && now - hit.at < MAGNET_READ_TTL_MS) return hit.read;
|
|
115
|
+
for (const [key, entry] of magnetReads) {
|
|
116
|
+
if (now - entry.at >= MAGNET_READ_TTL_MS) magnetReads.delete(key);
|
|
117
|
+
}
|
|
118
|
+
const read = fetchMagnet(slug);
|
|
119
|
+
if (magnetReads.size < MAGNET_READ_MAX_ENTRIES) magnetReads.set(slug, { at: now, read });
|
|
120
|
+
return read;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The landing's title and line: the record's `landing` copy, else today's. */
|
|
124
|
+
async function landingCopy(
|
|
125
|
+
config: FlowConfig,
|
|
126
|
+
locale: string,
|
|
127
|
+
): Promise<{ title: string; description: string; meta?: string; cta: string }> {
|
|
128
|
+
const t = await getTranslations({ locale, namespace: "flow" });
|
|
129
|
+
const pick = (value: Record<string, string> | undefined): string | undefined =>
|
|
130
|
+
value === undefined ? undefined : resolveLocalizedString(value, locale, config.defaultLocale);
|
|
131
|
+
const { landing } = config;
|
|
132
|
+
const meta = pick(landing?.meta);
|
|
133
|
+
return {
|
|
134
|
+
title: pick(landing?.title) ?? pick(config.steps[0]!.copy.title)!,
|
|
135
|
+
description: pick(landing?.description) ?? t("landingIntro"),
|
|
136
|
+
...(meta !== undefined ? { meta } : {}),
|
|
137
|
+
cta: pick(landing?.cta) ?? t("getStarted"),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The site's contact email in place of a magnet the hub cannot serve (D8).
|
|
143
|
+
* With a `title`, the page's own `<h1>` sits above it (#9219).
|
|
144
|
+
*/
|
|
145
|
+
export async function magnetUnavailable(locale: string, title?: string): Promise<ReactElement> {
|
|
146
|
+
const t = await getTranslations({ locale, namespace: "flow" });
|
|
147
|
+
return (
|
|
148
|
+
<Container className="py-section">
|
|
149
|
+
<div className="mx-auto max-w-container-sm">
|
|
150
|
+
{title !== undefined ? (
|
|
151
|
+
<Heading level={1} className="mb-block-tight">
|
|
152
|
+
{title}
|
|
153
|
+
</Heading>
|
|
154
|
+
) : null}
|
|
155
|
+
<ContactFallback
|
|
156
|
+
email={readSiteContactEmail()}
|
|
157
|
+
text={{
|
|
158
|
+
heading: t("unavailableHeading"),
|
|
159
|
+
body: t("unavailableBody"),
|
|
160
|
+
noEmail: t("unavailableNoEmail"),
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
</Container>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface MagnetLandingInput {
|
|
169
|
+
slug: string;
|
|
170
|
+
/** The landing's own path, e.g. `/diagnostic`; the CTA goes to `<path>/start`. */
|
|
171
|
+
path: string;
|
|
172
|
+
locale: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The indexable landing of a magnet pair (D8). */
|
|
176
|
+
export async function renderMagnetLanding({
|
|
177
|
+
slug,
|
|
178
|
+
path,
|
|
179
|
+
locale,
|
|
180
|
+
}: MagnetLandingInput): Promise<ReactElement> {
|
|
181
|
+
const read = await readMagnet(slug);
|
|
182
|
+
if (read.kind === "absent") notFound();
|
|
183
|
+
if (read.kind !== "ok") return magnetUnavailable(locale);
|
|
184
|
+
// #8848 D7 — a deliverable's one route.
|
|
185
|
+
if (read.config.deliverable !== undefined) redirect(localeHref(locale, `/reports/${slug}`));
|
|
186
|
+
|
|
187
|
+
const copy = await landingCopy(read.config, locale);
|
|
188
|
+
const sections: PageSection[] = [
|
|
189
|
+
{
|
|
190
|
+
id: "flow-landing-hero",
|
|
191
|
+
type: "hero",
|
|
192
|
+
data: {
|
|
193
|
+
variant: "centered",
|
|
194
|
+
headline: copy.title,
|
|
195
|
+
subheadline: copy.description,
|
|
196
|
+
...(copy.meta !== undefined ? { body: copy.meta } : {}),
|
|
197
|
+
primaryCta: { label: copy.cta, href: localeHref(locale, `${path}/start`) },
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<>
|
|
204
|
+
<JsonLd
|
|
205
|
+
schema={buildJsonLd({
|
|
206
|
+
type: "Assessment",
|
|
207
|
+
slug: read.config.slug,
|
|
208
|
+
title: copy.title,
|
|
209
|
+
description: copy.description,
|
|
210
|
+
locale,
|
|
211
|
+
})}
|
|
212
|
+
/>
|
|
213
|
+
<main>
|
|
214
|
+
<SectionRenderer sections={sections} locale={locale} />
|
|
215
|
+
</main>
|
|
216
|
+
</>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The landing's metadata: indexable under the site's own rule; a fallback never is. */
|
|
221
|
+
export async function renderMagnetLandingMetadata({
|
|
222
|
+
slug,
|
|
223
|
+
locale,
|
|
224
|
+
}: Pick<MagnetLandingInput, "slug" | "locale">): Promise<Metadata> {
|
|
225
|
+
const read = await readMagnet(slug);
|
|
226
|
+
if (read.kind === "absent") return {};
|
|
227
|
+
if (read.kind !== "ok") return flowRouteMetadata();
|
|
228
|
+
const copy = await landingCopy(read.config, locale);
|
|
229
|
+
return { title: copy.title, description: copy.description };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface MagnetStartInput {
|
|
233
|
+
slug: string;
|
|
234
|
+
locale: string;
|
|
235
|
+
/** Where the back arrow goes, unlocalized (the landing, or the posting). */
|
|
236
|
+
backHref: string;
|
|
237
|
+
/** Page-supplied `hidden` field values (a posting's slug as `job`). */
|
|
238
|
+
hiddenValues?: Readonly<Record<string, string>>;
|
|
239
|
+
/** Replaces step 1's title at every locale it holds (a posting's title). */
|
|
240
|
+
firstStepTitle?: string;
|
|
241
|
+
/** #8848 D7 — the deliverable's own walk (`/reports/<slug>/start`): no redirect. */
|
|
242
|
+
asDeliverable?: boolean;
|
|
243
|
+
/**
|
|
244
|
+
* #9219 — a walk that exists apart from its flow (`/careers/apply`): the
|
|
245
|
+
* `<h1>` of its fallback, which an absent flow renders too, never a 404.
|
|
246
|
+
*/
|
|
247
|
+
title?: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function withFirstStepTitle(config: FlowConfig, title: string): FlowConfig {
|
|
251
|
+
const [first, ...rest] = config.steps;
|
|
252
|
+
if (!first) return config;
|
|
253
|
+
const titled = Object.fromEntries(Object.keys(first.copy.title).map((key) => [key, title]));
|
|
254
|
+
return { ...config, steps: [{ ...first, copy: { ...first.copy, title: titled } }, ...rest] };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The chrome-free, noindex walk of a magnet pair (D8). */
|
|
258
|
+
export async function renderMagnetStart({
|
|
259
|
+
slug,
|
|
260
|
+
locale,
|
|
261
|
+
backHref,
|
|
262
|
+
hiddenValues,
|
|
263
|
+
firstStepTitle,
|
|
264
|
+
asDeliverable = false,
|
|
265
|
+
title,
|
|
266
|
+
}: MagnetStartInput): Promise<ReactElement> {
|
|
267
|
+
const read = await readMagnet(slug);
|
|
268
|
+
if (read.kind === "absent" && title === undefined) notFound();
|
|
269
|
+
if (read.kind === "ok" && read.config.deliverable !== undefined && !asDeliverable) {
|
|
270
|
+
redirect(localeHref(locale, `/reports/${slug}/start`));
|
|
271
|
+
}
|
|
272
|
+
const t = await getTranslations({ locale, namespace: "flow" });
|
|
273
|
+
const back = <NavBackLink href={localeHref(locale, backHref)} label={t("back")} />;
|
|
274
|
+
if (read.kind !== "ok") {
|
|
275
|
+
return (
|
|
276
|
+
<>
|
|
277
|
+
<div className="px-control-x pt-control-y">{back}</div>
|
|
278
|
+
{await magnetUnavailable(locale, title)}
|
|
279
|
+
</>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const config =
|
|
284
|
+
firstStepTitle !== undefined ? withFirstStepTitle(read.config, firstStepTitle) : read.config;
|
|
285
|
+
return (
|
|
286
|
+
<FlowStepperClient
|
|
287
|
+
config={config}
|
|
288
|
+
locale={locale}
|
|
289
|
+
slug={read.config.slug}
|
|
290
|
+
header={back}
|
|
291
|
+
{...(hiddenValues !== undefined ? { hiddenValues } : {})}
|
|
292
|
+
/>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The walk's metadata: always `noindex, follow` (`flowRouteMetadata`). */
|
|
297
|
+
export async function renderMagnetStartMetadata({
|
|
298
|
+
slug,
|
|
299
|
+
locale,
|
|
300
|
+
firstStepTitle,
|
|
301
|
+
title,
|
|
302
|
+
}: Pick<MagnetStartInput, "slug" | "locale" | "firstStepTitle" | "title">): Promise<Metadata> {
|
|
303
|
+
const read = await readMagnet(slug);
|
|
304
|
+
if (read.kind !== "ok") return flowRouteMetadata(title !== undefined ? { title } : {});
|
|
305
|
+
const firstStep = read.config.steps[0];
|
|
306
|
+
if (!firstStep) return flowRouteMetadata();
|
|
307
|
+
return flowRouteMetadata({
|
|
308
|
+
title:
|
|
309
|
+
firstStepTitle ??
|
|
310
|
+
resolveLocalizedString(firstStep.copy.title, locale, read.config.defaultLocale),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ─── Factories for a pair at a path (D8, D12) ──────────────────────────────
|
|
315
|
+
|
|
316
|
+
/** A magnet pair: the flow it walks, and the landing's own path. */
|
|
317
|
+
export interface MagnetRef {
|
|
318
|
+
slug: string;
|
|
319
|
+
path: string;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
interface MagnetPageProps {
|
|
323
|
+
params: Promise<{ locale: string }>;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function magnetLanding(ref: MagnetRef) {
|
|
327
|
+
return async function MagnetLandingPage({ params }: MagnetPageProps): Promise<ReactElement> {
|
|
328
|
+
const { locale } = await params;
|
|
329
|
+
return renderMagnetLanding({ slug: ref.slug, path: ref.path, locale });
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function magnetLandingMetadata(ref: MagnetRef) {
|
|
334
|
+
return async function generateMetadata({ params }: MagnetPageProps): Promise<Metadata> {
|
|
335
|
+
const { locale } = await params;
|
|
336
|
+
return renderMagnetLandingMetadata({ slug: ref.slug, locale });
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function magnetStart(ref: MagnetRef) {
|
|
341
|
+
return async function MagnetStartPage({ params }: MagnetPageProps): Promise<ReactElement> {
|
|
342
|
+
const { locale } = await params;
|
|
343
|
+
return renderMagnetStart({ slug: ref.slug, locale, backHref: ref.path });
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function magnetStartMetadata(ref: MagnetRef) {
|
|
348
|
+
return async function generateMetadata({ params }: MagnetPageProps): Promise<Metadata> {
|
|
349
|
+
const { locale } = await params;
|
|
350
|
+
return renderMagnetStartMetadata({ slug: ref.slug, locale });
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// oss: false
|
|
2
2
|
/**
|
|
3
|
-
* /[locale]/flows/[slug] — the tool's LANDING route (#5053 D3/D5).
|
|
3
|
+
* /[locale]/flows/[slug] — the tool's LANDING route (#5053 D3/D5). Standard
|
|
4
|
+
* magnet page (#8840): rendered by `magnet-page.tsx`'s `renderMagnetLanding`
|
|
5
|
+
* with the pair's path `/flows/<slug>`.
|
|
4
6
|
*
|
|
5
7
|
* An interactive tool is TWO routes. This is the indexed half: the document
|
|
6
8
|
* that describes the tool, carries its structured data, and links to the
|
|
@@ -18,92 +20,29 @@
|
|
|
18
20
|
* resting on #5019, which names `assessment` and `calculator` among the
|
|
19
21
|
* builders that keep that shape byte for byte).
|
|
20
22
|
*
|
|
21
|
-
* Copy:
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* Same `flows.get` fetch and same 404-on-`!res.ok` as the flow route: a flow
|
|
28
|
-
* that is absent or unconfigured has no landing either. Dynamic rendering
|
|
29
|
-
* for the same reason — the config is DB-backed live data.
|
|
23
|
+
* Copy (#8840 D8): the flow record's `landing` block — its title, line, meta
|
|
24
|
+
* line and button — else the flow's own first-step title and the site's
|
|
25
|
+
* `flow` messages. No consuming site's wording ships here. A flow that is
|
|
26
|
+
* absent has no landing either; a hub that cannot be reached shows the site's
|
|
27
|
+
* contact email. Dynamic rendering — the config is DB-backed live data.
|
|
30
28
|
*/
|
|
31
|
-
import { flows } from "@working-theory/api-client";
|
|
32
|
-
import { JsonLd } from "@working-theory/jsonld";
|
|
33
|
-
import { buildJsonLd } from "@working-theory/schema";
|
|
34
|
-
import { SectionRenderer } from "@working-theory/ui/sections";
|
|
35
|
-
import type { FlowConfig, PageSection } from "@working-theory/validation";
|
|
36
|
-
import { resolveLocalizedString } from "@working-theory/validation";
|
|
37
29
|
import type { Metadata } from "next";
|
|
38
|
-
import { notFound } from "next/navigation";
|
|
39
|
-
import { getTranslations } from "next-intl/server";
|
|
40
30
|
import type { ReactElement } from "react";
|
|
41
31
|
|
|
32
|
+
import { renderMagnetLanding, renderMagnetLandingMetadata } from "./magnet-page";
|
|
33
|
+
|
|
42
34
|
export const dynamic = "force-dynamic";
|
|
43
35
|
|
|
44
36
|
interface Props {
|
|
45
37
|
params: Promise<{ locale: string; slug: string }>;
|
|
46
38
|
}
|
|
47
39
|
|
|
48
|
-
/** The tool's name — the flow's own first-step title in the active locale. */
|
|
49
|
-
function toolName(config: FlowConfig, locale: string): string {
|
|
50
|
-
return resolveLocalizedString(
|
|
51
|
-
config.steps[0]!.copy.title,
|
|
52
|
-
locale,
|
|
53
|
-
config.defaultLocale,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
40
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
58
41
|
const { locale, slug } = await params;
|
|
59
|
-
|
|
60
|
-
if (!res.ok) return {};
|
|
61
|
-
|
|
62
|
-
const t = await getTranslations({ locale, namespace: "flow" });
|
|
63
|
-
return {
|
|
64
|
-
title: toolName(res.body, locale),
|
|
65
|
-
description: t("landingIntro"),
|
|
66
|
-
};
|
|
42
|
+
return renderMagnetLandingMetadata({ slug, locale });
|
|
67
43
|
}
|
|
68
44
|
|
|
69
45
|
export default async function Page({ params }: Props): Promise<ReactElement> {
|
|
70
46
|
const { locale, slug } = await params;
|
|
71
|
-
|
|
72
|
-
if (!res.ok) {
|
|
73
|
-
notFound();
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const t = await getTranslations({ locale, namespace: "flow" });
|
|
77
|
-
const name = toolName(res.body, locale);
|
|
78
|
-
const intro = t("landingIntro");
|
|
79
|
-
const startHref = `/flows/${res.body.slug}/start`;
|
|
80
|
-
|
|
81
|
-
const sections: PageSection[] = [
|
|
82
|
-
{
|
|
83
|
-
id: "flow-landing-hero",
|
|
84
|
-
type: "hero",
|
|
85
|
-
data: {
|
|
86
|
-
headline: name,
|
|
87
|
-
subheadline: intro,
|
|
88
|
-
primaryCta: { label: t("getStarted"), href: startHref },
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
|
-
];
|
|
92
|
-
|
|
93
|
-
return (
|
|
94
|
-
<>
|
|
95
|
-
<JsonLd
|
|
96
|
-
schema={buildJsonLd({
|
|
97
|
-
type: "Assessment",
|
|
98
|
-
slug: res.body.slug,
|
|
99
|
-
title: name,
|
|
100
|
-
description: intro,
|
|
101
|
-
locale,
|
|
102
|
-
})}
|
|
103
|
-
/>
|
|
104
|
-
<main>
|
|
105
|
-
<SectionRenderer sections={sections} locale={locale} />
|
|
106
|
-
</main>
|
|
107
|
-
</>
|
|
108
|
-
);
|
|
47
|
+
return renderMagnetLanding({ slug, path: `/flows/${slug}`, locale });
|
|
109
48
|
}
|
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
// oss: false
|
|
2
2
|
/**
|
|
3
3
|
* /[locale]/flows/[slug]/start — the flow half of the two-route tool
|
|
4
|
-
* (#5053 D3/D5).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* (#5053 D3/D5). Standard magnet page (#8840): rendered by
|
|
5
|
+
* `magnet-page.tsx`'s `renderMagnetStart`, chrome-free with the back arrow to
|
|
6
|
+
* `/flows/<slug>` (`lib/magnet-routes.ts`).
|
|
7
7
|
*
|
|
8
|
-
* `flowRouteMetadata` (`@working-theory/metadata
|
|
9
|
-
* follow` — the whole point of D4 is that this route does
|
|
10
|
-
* robots value, it gets it from what it mounts. `follow`, not
|
|
11
|
-
* the links out of a flow still carry.
|
|
8
|
+
* `flowRouteMetadata` (`@working-theory/metadata`, mounted by the renderer)
|
|
9
|
+
* supplies `noindex, follow` — the whole point of D4 is that this route does
|
|
10
|
+
* NOT write the robots value, it gets it from what it mounts. `follow`, not
|
|
11
|
+
* `nofollow`: the links out of a flow still carry.
|
|
12
12
|
*/
|
|
13
|
-
import { flows } from "@working-theory/api-client";
|
|
14
|
-
import { flowRouteMetadata } from "@working-theory/metadata";
|
|
15
|
-
import { resolveLocalizedString } from "@working-theory/validation";
|
|
16
13
|
import type { Metadata } from "next";
|
|
17
|
-
import { notFound } from "next/navigation";
|
|
18
14
|
import type { ReactElement } from "react";
|
|
19
15
|
|
|
20
|
-
import {
|
|
16
|
+
import { renderMagnetStart, renderMagnetStartMetadata } from "../magnet-page";
|
|
21
17
|
|
|
22
18
|
export const dynamic = "force-dynamic";
|
|
23
19
|
|
|
@@ -27,27 +23,10 @@ interface Props {
|
|
|
27
23
|
|
|
28
24
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
29
25
|
const { locale, slug } = await params;
|
|
30
|
-
|
|
31
|
-
if (!res.ok) return flowRouteMetadata();
|
|
32
|
-
|
|
33
|
-
const firstStep = res.body.steps[0];
|
|
34
|
-
if (!firstStep) return flowRouteMetadata();
|
|
35
|
-
|
|
36
|
-
return flowRouteMetadata({
|
|
37
|
-
title: resolveLocalizedString(
|
|
38
|
-
firstStep.copy.title,
|
|
39
|
-
locale,
|
|
40
|
-
res.body.defaultLocale,
|
|
41
|
-
),
|
|
42
|
-
});
|
|
26
|
+
return renderMagnetStartMetadata({ slug, locale });
|
|
43
27
|
}
|
|
44
28
|
|
|
45
29
|
export default async function Page({ params }: Props): Promise<ReactElement> {
|
|
46
30
|
const { locale, slug } = await params;
|
|
47
|
-
|
|
48
|
-
if (!res.ok) {
|
|
49
|
-
notFound();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return <FlowStepperClient config={res.body} locale={locale} slug={slug} />;
|
|
31
|
+
return renderMagnetStart({ slug, locale, backHref: `/flows/${slug}` });
|
|
53
32
|
}
|
|
@@ -80,7 +80,7 @@ describe("ForPage — snapshot regression", () => {
|
|
|
80
80
|
});
|
|
81
81
|
const { container } = render(element);
|
|
82
82
|
expect(container.innerHTML).toMatchInlineSnapshot(
|
|
83
|
-
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Use Cases","description":"How teams use our platform.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Article"}]}}</script><main id="main"><div><h1>Use Cases</h1><p data-testid="for-description" class="mt-block-tight text-lead">How teams use our platform.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium transition-
|
|
83
|
+
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Use Cases","description":"How teams use our platform.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Article"}]}}</script><main id="main"><div><h1>Use Cases</h1><p data-testid="for-description" class="mt-block-tight text-lead">How teams use our platform.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div data-testid="mdx-remote" data-source=""></div></article></div></div></div></main>"`,
|
|
84
84
|
);
|
|
85
85
|
});
|
|
86
86
|
});
|
|
@@ -58,7 +58,7 @@ describe("how-to/[slug] — snapshot regression", () => {
|
|
|
58
58
|
});
|
|
59
59
|
const { container } = render(element);
|
|
60
60
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
61
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Deploy AILK to Vercel","description":"Step-by-step guide to deploying AI Launch Kit on Vercel.","mainEntity":{"@context":"https://schema.org","@type":"HowTo","name":"Deploy AILK to Vercel","description":"Step-by-step guide to deploying AI Launch Kit on Vercel."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"How-To Guides","item":"http://localhost:3000/en/how-to"},{"@type":"ListItem","position":3,"name":"Deploy AILK to Vercel","item":"http://localhost:3000/en/how-to/deploy-ailk-vercel"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">Deploy AILK to Vercel</h1><p data-testid="how-to-description" class="mt-block-tight text-lead">Step-by-step guide to deploying AI Launch Kit on Vercel.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium transition-
|
|
61
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Deploy AILK to Vercel","description":"Step-by-step guide to deploying AI Launch Kit on Vercel.","mainEntity":{"@context":"https://schema.org","@type":"HowTo","name":"Deploy AILK to Vercel","description":"Step-by-step guide to deploying AI Launch Kit on Vercel."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"How-To Guides","item":"http://localhost:3000/en/how-to"},{"@type":"ListItem","position":3,"name":"Deploy AILK to Vercel","item":"http://localhost:3000/en/how-to/deploy-ailk-vercel"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">Deploy AILK to Vercel</h1><p data-testid="how-to-description" class="mt-block-tight text-lead">Step-by-step guide to deploying AI Launch Kit on Vercel.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div class="ailk-prose max-w-none text-(--color-text-muted) text-body-sm leading-relaxed [&>*:first-child]:mt-0 [&_h2]:mt-block-loose [&_h2]:scroll-mt-24 [&_h2]:text-heading-3 [&_h2]:font-display [&_h2]:text-(--color-fg) [&_h3]:mt-block-loose [&_h3]:scroll-mt-24 [&_h3]:text-heading-4 [&_h3]:font-display [&_h3]:text-(--color-fg) [&_h4]:mt-block-tight [&_h4]:scroll-mt-24 [&_h4]:text-heading-4 [&_h4]:font-display [&_h4]:text-(--color-fg) [&_h5]:mt-block-tight [&_h5]:scroll-mt-24 [&_h5]:text-heading-4 [&_h5]:font-display [&_h5]:text-(--color-fg) [&_h6]:mt-block-tight [&_h6]:scroll-mt-24 [&_h6]:text-heading-4 [&_h6]:font-display [&_h6]:text-(--color-fg) [&_p]:mt-paragraph [&_strong]:font-semibold [&_strong]:text-(--color-fg) [&_em]:italic [&_ul]:mt-paragraph [&_ul]:list-disc [&_ul]:ps-6 [&_ol]:mt-paragraph [&_ol]:list-decimal [&_ol]:ps-6 [&_li]:mt-1 [&_li]:ps-1 [&_li]:marker:text-(--color-text-muted) [&_li>ul]:mt-1 [&_li>ol]:mt-1 [&_blockquote]:mt-paragraph [&_blockquote]:border-s-4 [&_blockquote]:border-(--color-border-strong) [&_blockquote]:ps-4 [&_blockquote]:italic [&_blockquote]:text-(--color-text-muted) [&_hr]:my-block-loose [&_hr]:border-(--color-border) [&_a]:text-(--color-accent) [&_a]:underline [&_a]:underline-offset-4 [&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-(--color-surface-secondary) [&_:not(pre)>code]:px-1.5 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:text-code [&_:not(pre)>code]:font-mono [&_:not(pre)>code]:text-(--color-fg) [&_pre]:mt-paragraph [&_pre]:overflow-auto [&_pre]:rounded-md [&_pre]:border-hairline [&_pre]:border-(--color-border) [&_pre]:bg-(--color-bg) [&_pre]:p-4 [&_pre]:font-mono [&_pre]:text-body-sm [&_img]:mt-paragraph [&_img]:h-auto [&_img]:max-w-full [&_img]:rounded-lg [&_table]:mt-paragraph [&_table]:w-full [&_table]:border-collapse [&_table]:text-body-sm [&_th]:border-b [&_th]:border-(--color-border) [&_th]:bg-(--color-surface-secondary) [&_th]:px-3 [&_th]:py-2 [&_th]:text-start [&_th]:font-semibold [&_th]:text-(--color-fg) [&_td]:border-b [&_td]:border-(--color-border) [&_td]:px-3 [&_td]:py-2 [&_kbd]:rounded [&_kbd]:border-hairline [&_kbd]:border-(--color-border) [&_kbd]:bg-(--color-surface-secondary) [&_kbd]:px-1.5 [&_kbd]:py-0.5 [&_kbd]:text-caption [&_kbd]:font-mono" data-testid="mdx-content"><div data-testid="mdx-rendered">## Steps
|
|
62
62
|
|
|
63
63
|
Body content.</div></div></article></div></div></div></main>"
|
|
64
64
|
`);
|