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
|
@@ -70,7 +70,7 @@ describe("courses/page — snapshot regression", () => {
|
|
|
70
70
|
});
|
|
71
71
|
const { container } = render(element);
|
|
72
72
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
73
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Courses","description":"Structured learning paths for teams adopting AILK.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Course"}]}}</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">Courses</h1><p data-testid="courses-description" class="mt-block-tight text-lead">Structured learning paths for teams adopting AILK.</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-
|
|
73
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Courses","description":"Structured learning paths for teams adopting AILK.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Course"}]}}</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">Courses</h1><p data-testid="courses-description" class="mt-block-tight text-lead">Structured learning paths for teams adopting AILK.</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">## Courses
|
|
74
74
|
|
|
75
75
|
Body content.</div></div></article></div></div></div></main>"
|
|
76
76
|
`);
|
|
@@ -99,7 +99,7 @@ describe("courses/[slug] — snapshot regression", () => {
|
|
|
99
99
|
});
|
|
100
100
|
const { container } = render(element);
|
|
101
101
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
102
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AEO Fundamentals","description":"A practical course covering Layer 1 AEO rules.","mainEntity":{"@context":"https://schema.org","@type":"Course","name":"AEO Fundamentals","description":"A practical course covering Layer 1 AEO rules."}},{"@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":"Courses","item":"http://localhost:3000/en/courses"},{"@type":"ListItem","position":3,"name":"AEO Fundamentals","item":"http://localhost:3000/en/courses/aeo-fundamentals"}]}]}</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">AEO Fundamentals</h1><p data-testid="course-description" class="mt-block-tight text-lead">A practical course covering Layer 1 AEO rules.</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-
|
|
102
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AEO Fundamentals","description":"A practical course covering Layer 1 AEO rules.","mainEntity":{"@context":"https://schema.org","@type":"Course","name":"AEO Fundamentals","description":"A practical course covering Layer 1 AEO rules."}},{"@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":"Courses","item":"http://localhost:3000/en/courses"},{"@type":"ListItem","position":3,"name":"AEO Fundamentals","item":"http://localhost:3000/en/courses/aeo-fundamentals"}]}]}</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">AEO Fundamentals</h1><p data-testid="course-description" class="mt-block-tight text-lead">A practical course covering Layer 1 AEO rules.</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">## AEO Fundamentals
|
|
103
103
|
|
|
104
104
|
Body content.</div></div></article></div></div></div></main>"
|
|
105
105
|
`);
|
|
@@ -100,7 +100,7 @@ describe("EventsPage — snapshot regression", () => {
|
|
|
100
100
|
});
|
|
101
101
|
const { container } = render(element);
|
|
102
102
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
103
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Events","description":"Upcoming and past events.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Event"}]}}</script><main id="main"><div><h1>Events</h1><p data-testid="events-description" class="mt-block-tight text-lead">Upcoming and past events.</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-
|
|
103
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Events","description":"Upcoming and past events.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Event"}]}}</script><main id="main"><div><h1>Events</h1><p data-testid="events-description" class="mt-block-tight text-lead">Upcoming and past events.</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-rendered">
|
|
104
104
|
Body.</div></article></div></div></div></main>"
|
|
105
105
|
`);
|
|
106
106
|
});
|
|
@@ -134,7 +134,7 @@ describe("EventSlugPage — snapshot regression", () => {
|
|
|
134
134
|
// This replaces the pre-migration hardcoded "event-description" testid
|
|
135
135
|
// with the slug-derived "annual-summit-description". Intentional per spec.
|
|
136
136
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
137
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Annual Summit","description":"Our annual developer summit.","mainEntity":{"@context":"https://schema.org","@type":"Event","name":"Annual Summit","description":"Our annual developer summit."}},{"@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":"Events","item":"http://localhost:3000/en/events"},{"@type":"ListItem","position":3,"name":"Annual Summit","item":"http://localhost:3000/en/events/annual-summit"}]}]}</script><main id="main"><div><h1>Annual Summit</h1><p data-testid="annual-summit-description" class="mt-block-tight text-lead">Our annual developer summit.</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-
|
|
137
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Annual Summit","description":"Our annual developer summit.","mainEntity":{"@context":"https://schema.org","@type":"Event","name":"Annual Summit","description":"Our annual developer summit."}},{"@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":"Events","item":"http://localhost:3000/en/events"},{"@type":"ListItem","position":3,"name":"Annual Summit","item":"http://localhost:3000/en/events/annual-summit"}]}]}</script><main id="main"><div><h1>Annual Summit</h1><p data-testid="annual-summit-description" class="mt-block-tight text-lead">Our annual developer summit.</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-rendered">
|
|
138
138
|
Body.</div></article></div></div></div></main>"
|
|
139
139
|
`);
|
|
140
140
|
});
|
|
@@ -35,6 +35,35 @@
|
|
|
35
35
|
* a `thankYou` block, so without this widening the callback would never be
|
|
36
36
|
* supplied on a recommending flow at all.
|
|
37
37
|
*
|
|
38
|
+
* Account link (#8771 D5): when the walk completes on an `invite` magnet (the
|
|
39
|
+
* step response's `next`), the client asks `accountInviteLinkAction` for the
|
|
40
|
+
* sign-in link with the email the visitor gave already filled in, then shows
|
|
41
|
+
* "Track this in your account" on `FlowStepper`'s own finished panel — or,
|
|
42
|
+
* for a flow with a `thankYou` block, hands the link to `/thanks/<slug>`
|
|
43
|
+
* through `sessionStorage` (`ailk:account-invite`). The email never enters a
|
|
44
|
+
* URL the walk navigates to (NFR1), and each link sits in a Session-Replay
|
|
45
|
+
* `data-sentry-block` element, since the scrub cannot reach a DOM `href`.
|
|
46
|
+
*
|
|
47
|
+
* The standard magnet's parts (#8840 D9): the consent registry is the
|
|
48
|
+
* platform default with the locale's `/terms` and `/privacy` links
|
|
49
|
+
* (`consentPoliciesFor`, the map every agreement surface uses); a `message`
|
|
50
|
+
* step is shown but not counted in "Step N of M"; `hiddenValues` and `header`
|
|
51
|
+
* pass through from the page. When the config carries an `ending`, it is the
|
|
52
|
+
* one ending: `onCompleted` is not passed (so neither `thankYou` nor a
|
|
53
|
+
* recommending flow's permalink runs), and on the completing response the
|
|
54
|
+
* client picks the outcome from the answers it accumulated
|
|
55
|
+
* (`selectFlowEndingOutcome`) — a screen in the stepper's finished card,
|
|
56
|
+
* `{name}` filled from the walk, or a redirect to a site path. Every
|
|
57
|
+
* destination is re-checked against `flowSitePathSchema` before it renders or
|
|
58
|
+
* navigates (the served config is not re-parsed, SEC-8840-H).
|
|
59
|
+
*
|
|
60
|
+
* The deliverable (#8848 D8): a completing response's `deliverable` is drawn
|
|
61
|
+
* after the ending screen — the reader link for a file (its grant in `?g=`,
|
|
62
|
+
* inside a `data-sentry-block` element), an inline player for a video, a link
|
|
63
|
+
* for a page or an outside address — each target re-checked before it
|
|
64
|
+
* renders. A redirect ending never runs when the response carries one, and a
|
|
65
|
+
* gated file walk whose response has none says the link is in the email.
|
|
66
|
+
*
|
|
38
67
|
* Locale validation before interpolation (AILK #4707, security review on
|
|
39
68
|
* samhenry PR #163): the `[locale]` route segment is a Next.js dynamic
|
|
40
69
|
* segment — the framework matches ANY string there, and next-intl's
|
|
@@ -49,33 +78,57 @@
|
|
|
49
78
|
* bundle, the same reasoning `middleware.ts` already documents for the
|
|
50
79
|
* Edge runtime) before it ever reaches the template literal.
|
|
51
80
|
*/
|
|
81
|
+
import { Button, Heading, Link, localeHref, Text } from "@working-theory/ui";
|
|
82
|
+
import { VideoEmbed } from "@working-theory/ui/blocks";
|
|
52
83
|
import {
|
|
53
84
|
FlowStepper,
|
|
54
85
|
type FlowStepperMessages,
|
|
55
86
|
type FlowStepSubmitPayload,
|
|
56
87
|
type FlowStepSubmitResult,
|
|
57
88
|
} from "@working-theory/ui-signal-collectors";
|
|
58
|
-
import type {
|
|
89
|
+
import type {
|
|
90
|
+
AeoScoreResult,
|
|
91
|
+
FlowConfig,
|
|
92
|
+
FlowEndingOutcome,
|
|
93
|
+
FlowState,
|
|
94
|
+
FlowStepDeliverable,
|
|
95
|
+
} from "@working-theory/validation";
|
|
59
96
|
import {
|
|
60
97
|
deriveFlags,
|
|
61
98
|
encodePermalink,
|
|
99
|
+
fillEndingName,
|
|
62
100
|
flattenState,
|
|
101
|
+
flowHttpsUrlSchema,
|
|
102
|
+
flowSitePathSchema,
|
|
103
|
+
flowStepDeliverableSchema,
|
|
104
|
+
flowStepNextSchema,
|
|
63
105
|
hiddenAnswers,
|
|
64
106
|
PERMALINK_VERSION,
|
|
65
107
|
pruneHiddenState,
|
|
108
|
+
resolveLocalizedString,
|
|
66
109
|
resolveThankYouSlug,
|
|
110
|
+
selectFlowEndingOutcome,
|
|
111
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
112
|
+
visitorNameOf,
|
|
67
113
|
} from "@working-theory/validation";
|
|
68
114
|
import { useRouter } from "next/navigation";
|
|
69
115
|
import { useMessages, useTranslations } from "next-intl";
|
|
70
|
-
import type { ReactElement } from "react";
|
|
71
|
-
import { useRef } from "react";
|
|
116
|
+
import type { ReactElement, ReactNode } from "react";
|
|
117
|
+
import { useRef, useState } from "react";
|
|
118
|
+
|
|
119
|
+
import { consentPoliciesFor } from "../../../../lib/consent-policies";
|
|
72
120
|
|
|
121
|
+
import { accountInviteLinkAction } from "./account-invite.actions";
|
|
73
122
|
import { submitFlowStepAction } from "./flow.actions";
|
|
74
123
|
|
|
75
124
|
interface Props {
|
|
76
125
|
config: FlowConfig;
|
|
77
126
|
locale: string;
|
|
78
127
|
slug: string;
|
|
128
|
+
/** #8840 D9 — page-supplied `hidden` field values (a posting's slug). */
|
|
129
|
+
hiddenValues?: Readonly<Record<string, string>>;
|
|
130
|
+
/** #8840 D9 — the walk's back arrow, above the step. */
|
|
131
|
+
header?: ReactNode;
|
|
79
132
|
}
|
|
80
133
|
|
|
81
134
|
const DEFAULT_LOCALE = "en";
|
|
@@ -88,6 +141,46 @@ function resolveSafeLocale(candidate: string): string {
|
|
|
88
141
|
return ENABLED_LOCALES.includes(candidate) ? candidate : DEFAULT_LOCALE;
|
|
89
142
|
}
|
|
90
143
|
|
|
144
|
+
/** The `/thanks` hand-off key (#8771 D5) — read once by `AccountInviteLink`. */
|
|
145
|
+
const ACCOUNT_INVITE_STORAGE_KEY = "ailk:account-invite";
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* #8771 D5 — the email the engine will land as the lead's: the first
|
|
149
|
+
* `mapsTo: "email"` field of the submitted `field` step that holds a
|
|
150
|
+
* non-empty string. Mirrors `extractMappedEmail` (a later step wins).
|
|
151
|
+
*/
|
|
152
|
+
function mappedEmailOf(
|
|
153
|
+
config: FlowConfig,
|
|
154
|
+
payload: FlowStepSubmitPayload,
|
|
155
|
+
): string | undefined {
|
|
156
|
+
const step = config.steps.find((s) => s.key === payload.stepKey);
|
|
157
|
+
if (step?.type !== "field") return undefined;
|
|
158
|
+
for (const field of step.fields) {
|
|
159
|
+
if (field.mapsTo !== "email") continue;
|
|
160
|
+
const value = payload.values[field.name];
|
|
161
|
+
if (typeof value === "string" && value.trim() !== "") return value.trim();
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* #8848 D8 — the completing response's `deliverable`, each target re-checked:
|
|
168
|
+
* a video whose `src` is refused is nothing, a refused `poster` is dropped;
|
|
169
|
+
* every other kind must parse whole.
|
|
170
|
+
*/
|
|
171
|
+
function safeDeliverable(raw: unknown): FlowStepDeliverable | undefined {
|
|
172
|
+
if (typeof raw !== "object" || raw === null) return undefined;
|
|
173
|
+
const video = raw as { kind?: unknown; src?: unknown; poster?: unknown };
|
|
174
|
+
if (video.kind === "video") {
|
|
175
|
+
const src = flowHttpsUrlSchema.safeParse(video.src);
|
|
176
|
+
if (!src.success) return undefined;
|
|
177
|
+
const poster = flowHttpsUrlSchema.safeParse(video.poster);
|
|
178
|
+
return { kind: "video", src: src.data, ...(poster.success ? { poster: poster.data } : {}) };
|
|
179
|
+
}
|
|
180
|
+
const parsed = flowStepDeliverableSchema.safeParse(raw);
|
|
181
|
+
return parsed.success ? parsed.data : undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
91
184
|
/**
|
|
92
185
|
* D7 (#4770) — every question `name` contributed by a `segmentation`-typed
|
|
93
186
|
* step. This is the allowlist the encoded permalink is filtered to: a
|
|
@@ -106,10 +199,23 @@ function segmentationQuestionNames(config: FlowConfig): Set<string> {
|
|
|
106
199
|
return names;
|
|
107
200
|
}
|
|
108
201
|
|
|
202
|
+
/**
|
|
203
|
+
* #8840 D9 — the answers the ending is chosen from, assembled as the
|
|
204
|
+
* permalink path assembles them: the visible steps' answers flattened, then
|
|
205
|
+
* the hidden segmentation steps' `whenHidden` answers.
|
|
206
|
+
*/
|
|
207
|
+
function walkAnswers(config: FlowConfig, state: FlowState): Record<string, unknown> {
|
|
208
|
+
const flags = deriveFlags(config, state);
|
|
209
|
+
const flat = flattenState(pruneHiddenState(config, state, flags));
|
|
210
|
+
return Object.assign(flat, hiddenAnswers(config, state, flags));
|
|
211
|
+
}
|
|
212
|
+
|
|
109
213
|
export function FlowStepperClient({
|
|
110
214
|
config,
|
|
111
215
|
locale,
|
|
112
216
|
slug,
|
|
217
|
+
hiddenValues,
|
|
218
|
+
header,
|
|
113
219
|
}: Props): ReactElement {
|
|
114
220
|
const t = useTranslations("flow");
|
|
115
221
|
const router = useRouter();
|
|
@@ -130,6 +236,20 @@ export function FlowStepperClient({
|
|
|
130
236
|
// recommending completion can hand the walk's session to the results page
|
|
131
237
|
// via `sessionStorage` (never the URL — the permalink stays answers-only).
|
|
132
238
|
const lastSessionIdRef = useRef<string | undefined>(undefined);
|
|
239
|
+
// #8771 D5 — the email the walk has landed, and the account link built from
|
|
240
|
+
// it at completion on an `invite` magnet. The ref carries the link to
|
|
241
|
+
// `handleCompleted`; the state draws it on the finished panel.
|
|
242
|
+
const emailRef = useRef<string | undefined>(undefined);
|
|
243
|
+
const inviteHrefRef = useRef<string | null>(null);
|
|
244
|
+
const [inviteHref, setInviteHref] = useState<string | null>(null);
|
|
245
|
+
const tContact = useTranslations("contact");
|
|
246
|
+
// #8840 D9 — a config with an `ending` ends on it, never on `thankYou` or
|
|
247
|
+
// the permalink; the chosen screen is drawn in the finished card.
|
|
248
|
+
const endingWins = config.ending !== undefined;
|
|
249
|
+
const [endingContent, setEndingContent] = useState<ReactNode | undefined>(undefined);
|
|
250
|
+
// #8848 D8 — the deliverable's action, drawn after the ending screen.
|
|
251
|
+
const tReports = useTranslations("reports");
|
|
252
|
+
const [deliverableContent, setDeliverableContent] = useState<ReactNode | undefined>(undefined);
|
|
133
253
|
|
|
134
254
|
async function handleSubmitStep(
|
|
135
255
|
payload: FlowStepSubmitPayload,
|
|
@@ -141,12 +261,165 @@ export function FlowStepperClient({
|
|
|
141
261
|
[payload.stepKey]: payload.values,
|
|
142
262
|
};
|
|
143
263
|
lastSessionIdRef.current = result.response.sessionId;
|
|
264
|
+
emailRef.current = mappedEmailOf(config, payload) ?? emailRef.current;
|
|
265
|
+
if (result.response.completed) {
|
|
266
|
+
await resolveInviteLink(result.response);
|
|
267
|
+
const delivered = showDeliverable((result.response as { deliverable?: unknown }).deliverable);
|
|
268
|
+
if (endingWins) showEnding(delivered);
|
|
269
|
+
}
|
|
144
270
|
} else if (result.kind === "step_conflict") {
|
|
145
271
|
answersRef.current = {};
|
|
272
|
+
emailRef.current = undefined;
|
|
146
273
|
}
|
|
147
274
|
return result;
|
|
148
275
|
}
|
|
149
276
|
|
|
277
|
+
// #8771 D5 — on an `invite` completion with a landed email, ask the server
|
|
278
|
+
// for the prefilled sign-in link. A rejected or null answer gives no link.
|
|
279
|
+
async function resolveInviteLink(response: unknown): Promise<void> {
|
|
280
|
+
inviteHrefRef.current = null;
|
|
281
|
+
setInviteHref(null);
|
|
282
|
+
const next = flowStepNextSchema.safeParse(
|
|
283
|
+
(response as { next?: unknown }).next,
|
|
284
|
+
);
|
|
285
|
+
const email = emailRef.current;
|
|
286
|
+
if (!next.success || email === undefined || isRecommending) return;
|
|
287
|
+
try {
|
|
288
|
+
const href = await accountInviteLinkAction(email);
|
|
289
|
+
inviteHrefRef.current = href;
|
|
290
|
+
setInviteHref(href);
|
|
291
|
+
} catch {
|
|
292
|
+
// No link — the walk still completes.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// #8848 D8 — draw the response's deliverable (true when there is one), or,
|
|
297
|
+
// for a gated file walk that minted no grant, the by-email line.
|
|
298
|
+
function showDeliverable(raw: unknown): boolean {
|
|
299
|
+
const deliverable = safeDeliverable(raw);
|
|
300
|
+
if (deliverable !== undefined) {
|
|
301
|
+
setDeliverableContent(deliverableAction(deliverable));
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
const block = config.deliverable;
|
|
305
|
+
setDeliverableContent(
|
|
306
|
+
raw === undefined && block?.kind === "file" && block.gated ? (
|
|
307
|
+
<Text className="mt-block-tight" data-testid="flow-deliverable-by-email">
|
|
308
|
+
{tReports("fileByEmail")}
|
|
309
|
+
</Text>
|
|
310
|
+
) : undefined,
|
|
311
|
+
);
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function deliverableAction(deliverable: FlowStepDeliverable): ReactNode {
|
|
316
|
+
const pick = (value: Record<string, string> | undefined): string | undefined =>
|
|
317
|
+
value === undefined ? undefined : resolveLocalizedString(value, locale, config.defaultLocale);
|
|
318
|
+
const label = pick(config.deliverable?.cta);
|
|
319
|
+
if (deliverable.kind === "video") {
|
|
320
|
+
return (
|
|
321
|
+
<div className="mt-block-loose" data-testid="flow-deliverable-video">
|
|
322
|
+
<VideoEmbed
|
|
323
|
+
sources={[{ src: deliverable.src }]}
|
|
324
|
+
{...(deliverable.poster !== undefined ? { poster: deliverable.poster } : {})}
|
|
325
|
+
label={pick(config.deliverable?.title) ?? tReports("openFile")}
|
|
326
|
+
/>
|
|
327
|
+
</div>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const href =
|
|
331
|
+
deliverable.kind === "file"
|
|
332
|
+
? localeHref(locale, `/reports/${slug}?g=${encodeURIComponent(deliverable.grantId)}`)
|
|
333
|
+
: deliverable.kind === "page"
|
|
334
|
+
? localeHref(locale, deliverable.href)
|
|
335
|
+
: deliverable.href;
|
|
336
|
+
const fallback = deliverable.kind === "external" ? tReports("openExternal") : tReports("openFile");
|
|
337
|
+
const link = (
|
|
338
|
+
<Button asChild variant="default" className="min-h-11">
|
|
339
|
+
<Link
|
|
340
|
+
href={href}
|
|
341
|
+
variant="bare"
|
|
342
|
+
{...(deliverable.kind === "external" ? { external: true } : {})}
|
|
343
|
+
data-testid="flow-deliverable-action"
|
|
344
|
+
>
|
|
345
|
+
{label ?? fallback}
|
|
346
|
+
</Link>
|
|
347
|
+
</Button>
|
|
348
|
+
);
|
|
349
|
+
// Session Replay records `href`s; the file's carries a bearer grant.
|
|
350
|
+
return deliverable.kind === "file" ? (
|
|
351
|
+
<div data-sentry-block className="mt-block-loose flex justify-center">
|
|
352
|
+
{link}
|
|
353
|
+
</div>
|
|
354
|
+
) : (
|
|
355
|
+
<div className="mt-block-loose flex justify-center">{link}</div>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// #8840 D9 — the ending for the walk's answers: a redirect to a site path,
|
|
360
|
+
// else a screen; a destination the schema refuses is never rendered or
|
|
361
|
+
// pushed (a refused redirect falls back to a screen `default`, else to the
|
|
362
|
+
// stepper's own finished panel). #8848 D8 — with a deliverable in hand a
|
|
363
|
+
// redirect never runs, so the deliverable is never lost to a navigation.
|
|
364
|
+
function showEnding(delivered = false): void {
|
|
365
|
+
const answers = walkAnswers(config, answersRef.current);
|
|
366
|
+
const segmentationNames = segmentationQuestionNames(config);
|
|
367
|
+
const choices: Record<string, string | string[]> = {};
|
|
368
|
+
for (const [name, value] of Object.entries(answers)) {
|
|
369
|
+
if (segmentationNames.has(name) && (typeof value === "string" || Array.isArray(value))) {
|
|
370
|
+
choices[name] = value as string | string[];
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const outcome = selectFlowEndingOutcome(config, choices);
|
|
374
|
+
if (outcome === undefined) return;
|
|
375
|
+
if (outcome.kind === "redirect") {
|
|
376
|
+
if (!delivered && flowSitePathSchema.safeParse(outcome.href).success) {
|
|
377
|
+
setEndingContent(<></>);
|
|
378
|
+
router.push(`/${resolveSafeLocale(locale)}${outcome.href}`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const fallback = config.ending?.default;
|
|
382
|
+
if (fallback?.kind === "screen") setEndingContent(endingScreen(fallback, answers));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
setEndingContent(endingScreen(outcome, answers));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function endingScreen(
|
|
389
|
+
outcome: Extract<FlowEndingOutcome, { kind: "screen" }>,
|
|
390
|
+
answers: Record<string, unknown>,
|
|
391
|
+
): ReactNode {
|
|
392
|
+
const name = visitorNameOf(config, answers);
|
|
393
|
+
const text = (value: Record<string, string>) =>
|
|
394
|
+
fillEndingName(resolveLocalizedString(value, locale, config.defaultLocale), name);
|
|
395
|
+
const ctas = (outcome.ctas ?? []).filter(
|
|
396
|
+
(cta) => flowSitePathSchema.safeParse(cta.href).success,
|
|
397
|
+
);
|
|
398
|
+
return (
|
|
399
|
+
<div data-testid="flow-ending">
|
|
400
|
+
<Heading level={2}>{text(outcome.headline)}</Heading>
|
|
401
|
+
{outcome.body !== undefined ? (
|
|
402
|
+
<Text className="mt-block-tight">{text(outcome.body)}</Text>
|
|
403
|
+
) : null}
|
|
404
|
+
{ctas.length > 0 ? (
|
|
405
|
+
<div className="mt-block-loose flex flex-wrap justify-center gap-4">
|
|
406
|
+
{ctas.map((cta) => (
|
|
407
|
+
<Button key={cta.href} asChild variant="default" className="min-h-11">
|
|
408
|
+
<Link
|
|
409
|
+
href={localeHref(locale, cta.href)}
|
|
410
|
+
variant="bare"
|
|
411
|
+
data-testid="flow-ending-cta"
|
|
412
|
+
>
|
|
413
|
+
{text(cta.label)}
|
|
414
|
+
</Link>
|
|
415
|
+
</Button>
|
|
416
|
+
))}
|
|
417
|
+
</div>
|
|
418
|
+
) : null}
|
|
419
|
+
</div>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
150
423
|
// #4663 D11 — `aeoScore` passes through untouched: this repo's segment
|
|
151
424
|
// redirect is unchanged; C2's results page (on samhenry) is the consumer
|
|
152
425
|
// of the score.
|
|
@@ -209,6 +482,26 @@ export function FlowStepperClient({
|
|
|
209
482
|
|
|
210
483
|
if (!thankYou) return;
|
|
211
484
|
const thankYouSlug = resolveThankYouSlug(thankYou, result.segment);
|
|
485
|
+
// #8771 D5 — hand the link to /thanks through the per-tab sessionStorage,
|
|
486
|
+
// never the URL; with no link, clear an earlier walk's so it never shows
|
|
487
|
+
// on this page. A storage that throws gives no link.
|
|
488
|
+
if (typeof window !== "undefined") {
|
|
489
|
+
try {
|
|
490
|
+
if (inviteHrefRef.current !== null) {
|
|
491
|
+
window.sessionStorage.setItem(
|
|
492
|
+
ACCOUNT_INVITE_STORAGE_KEY,
|
|
493
|
+
JSON.stringify({
|
|
494
|
+
thanks: thankYouSlug,
|
|
495
|
+
href: inviteHrefRef.current,
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
} else {
|
|
499
|
+
window.sessionStorage.removeItem(ACCOUNT_INVITE_STORAGE_KEY);
|
|
500
|
+
}
|
|
501
|
+
} catch {
|
|
502
|
+
// No hand-off.
|
|
503
|
+
}
|
|
504
|
+
}
|
|
212
505
|
router.push(`/${resolveSafeLocale(locale)}/thanks/${thankYouSlug}`);
|
|
213
506
|
}
|
|
214
507
|
|
|
@@ -252,15 +545,59 @@ export function FlowStepperClient({
|
|
|
252
545
|
selectAllThatApply: t("selectAllThatApply"),
|
|
253
546
|
};
|
|
254
547
|
|
|
548
|
+
// #8771 D5 — the link goes INSIDE FlowStepper's own finished card (a flow
|
|
549
|
+
// with a `thankYou` block navigates away instead, so it never shows there).
|
|
550
|
+
// Session Replay records the DOM, so the link (it carries the email) is
|
|
551
|
+
// blocked from it; `Link` does not forward an undeclared data-* attribute.
|
|
552
|
+
const completedAction =
|
|
553
|
+
inviteHref !== null && (endingWins || thankYou === undefined) && !isRecommending ? (
|
|
554
|
+
<div data-sentry-block>
|
|
555
|
+
<Button asChild variant="outline" className="mt-4 min-h-11">
|
|
556
|
+
<Link
|
|
557
|
+
href={inviteHref}
|
|
558
|
+
variant="bare"
|
|
559
|
+
data-testid="flow-account-invite"
|
|
560
|
+
>
|
|
561
|
+
{tContact("trackInAccount")}
|
|
562
|
+
</Link>
|
|
563
|
+
</Button>
|
|
564
|
+
</div>
|
|
565
|
+
) : undefined;
|
|
566
|
+
|
|
567
|
+
// #8848 D8 — the ending screen, then the deliverable; with no ending the
|
|
568
|
+
// stepper's own finished copy heads the action.
|
|
569
|
+
const completedContent =
|
|
570
|
+
deliverableContent !== undefined ? (
|
|
571
|
+
<>
|
|
572
|
+
{endingContent ?? (
|
|
573
|
+
<>
|
|
574
|
+
<Heading level={2}>{t("completedTitle")}</Heading>
|
|
575
|
+
<Text className="mt-block-tight">{t("completedBody")}</Text>
|
|
576
|
+
</>
|
|
577
|
+
)}
|
|
578
|
+
{deliverableContent}
|
|
579
|
+
</>
|
|
580
|
+
) : (
|
|
581
|
+
endingContent
|
|
582
|
+
);
|
|
583
|
+
|
|
255
584
|
return (
|
|
256
585
|
<FlowStepper
|
|
257
586
|
config={config}
|
|
258
587
|
locale={locale}
|
|
259
588
|
messages={messages}
|
|
260
589
|
onSubmitStep={handleSubmitStep}
|
|
261
|
-
{...(thankYou !== undefined || isRecommending
|
|
590
|
+
{...(!endingWins && (thankYou !== undefined || isRecommending)
|
|
262
591
|
? { onCompleted: handleCompleted }
|
|
263
592
|
: {})}
|
|
593
|
+
{...(completedAction !== undefined ? { completedAction } : {})}
|
|
594
|
+
// #8840 D9 — the locale's policy links, the input-step count, and the
|
|
595
|
+
// page's hidden values, header and ending.
|
|
596
|
+
consentConfig={{ ...SITE_CONSENT_CONFIG_DEFAULTS, policies: consentPoliciesFor(locale) }}
|
|
597
|
+
uncountedStepKeys={config.steps.filter((step) => step.type === "message").map((step) => step.key)}
|
|
598
|
+
{...(hiddenValues !== undefined ? { hiddenValues } : {})}
|
|
599
|
+
{...(header !== undefined ? { header } : {})}
|
|
600
|
+
{...(completedContent !== undefined ? { completedContent } : {})}
|
|
264
601
|
/>
|
|
265
602
|
);
|
|
266
603
|
}
|
package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* #8840 D9, AC-3 — unmocked: with the real FlowStepper, a walk of three input
|
|
4
|
+
* steps and a closing message reads "Step 1 of 3" — the closing message is
|
|
5
|
+
* shown but not counted.
|
|
6
|
+
*/
|
|
7
|
+
import { render, screen } from "@testing-library/react";
|
|
8
|
+
import type { FlowConfig } from "@working-theory/validation";
|
|
9
|
+
|
|
10
|
+
jest.mock("next/navigation", () => ({
|
|
11
|
+
useRouter: () => ({ push: jest.fn() }),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
jest.mock("next-intl", () => ({
|
|
15
|
+
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
|
16
|
+
key === "progress" ? `Step ${String(values?.["current"])} of ${String(values?.["total"])}` : key,
|
|
17
|
+
useMessages: () => ({}),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// eslint-disable-next-line no-restricted-syntax -- the boundary under test is the client wiring, not the actions
|
|
21
|
+
jest.mock("../flow.actions", () => ({ submitFlowStepAction: jest.fn() }));
|
|
22
|
+
// eslint-disable-next-line no-restricted-syntax -- see above
|
|
23
|
+
jest.mock("../account-invite.actions", () => ({ accountInviteLinkAction: jest.fn() }));
|
|
24
|
+
|
|
25
|
+
import { FlowStepperClient } from "../FlowStepperClient";
|
|
26
|
+
|
|
27
|
+
const en = (s: string) => ({ en: s });
|
|
28
|
+
const field = (key: string) => ({
|
|
29
|
+
key,
|
|
30
|
+
type: "field" as const,
|
|
31
|
+
copy: { title: en(key) },
|
|
32
|
+
fields: [{ name: key, inputType: "text" as const, copy: { label: en(key) } }],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("counts the three input steps and not the closing message", () => {
|
|
36
|
+
const config = {
|
|
37
|
+
slug: "three",
|
|
38
|
+
defaultLocale: "en",
|
|
39
|
+
steps: [field("one"), field("two"), field("three"), { key: "bye", type: "message", copy: { title: en("Bye") } }],
|
|
40
|
+
} as FlowConfig;
|
|
41
|
+
render(<FlowStepperClient config={config} locale="en" slug="three" />);
|
|
42
|
+
expect(screen.getByTestId("flow-stepper-progress-label").textContent).toBe("Step 1 of 3");
|
|
43
|
+
});
|