create-ailk 0.7.0 → 0.9.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/README.md +112 -10
- package/component-catalog.json +798 -49
- package/component-catalog.md +9 -6
- package/dist/add-module.d.ts +20 -0
- package/dist/add-module.js +43 -7
- package/dist/add-route.d.ts +56 -0
- package/dist/add-route.js +286 -0
- package/dist/apply-design-md.d.ts +7 -0
- package/dist/apply-design-md.js +50 -0
- package/dist/apply-design.d.ts +64 -0
- package/dist/apply-design.js +273 -0
- package/dist/claude-md.d.ts +111 -0
- package/dist/claude-md.js +334 -0
- package/dist/cli.js +114 -28
- package/dist/derive-architecture.js +41 -36
- package/dist/design-compile.d.ts +71 -0
- package/dist/design-compile.js +98 -0
- package/dist/design-package.d.ts +83 -0
- package/dist/design-package.js +468 -0
- package/dist/design-presets.d.ts +86 -0
- package/dist/design-presets.js +642 -0
- package/dist/design-scaffold.d.ts +76 -0
- package/dist/design-scaffold.js +220 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/legacy-nav-renderers.d.ts +45 -0
- package/dist/legacy-nav-renderers.js +545 -0
- package/dist/lib/auth-secret-file.d.ts +19 -0
- package/dist/lib/auth-secret-file.js +112 -0
- package/dist/lib/design-checks.d.ts +78 -0
- package/dist/lib/design-checks.js +174 -0
- package/dist/lib/extract-module-bundle.js +5 -1
- package/dist/lib/license-key-files.d.ts +9 -0
- package/dist/lib/license-key-files.js +2 -2
- package/dist/lib/module-license-gate.d.ts +89 -0
- package/dist/lib/module-license-gate.js +171 -12
- package/dist/lib/module-tier.d.ts +45 -16
- package/dist/lib/module-tier.js +76 -0
- package/dist/lib/pro-callout.js +10 -2
- package/dist/lib/render-verdict.d.ts +52 -0
- package/dist/lib/render-verdict.js +77 -0
- package/dist/lib/scaffold-render-sweep.d.ts +105 -0
- package/dist/lib/scaffold-render-sweep.js +236 -0
- package/dist/module-architecture.d.ts +19 -16
- package/dist/module-architecture.js +64 -21
- package/dist/parse-args.d.ts +88 -10
- package/dist/parse-args.js +272 -21
- package/dist/programmatic.d.ts +66 -12
- package/dist/programmatic.js +361 -34
- package/dist/site-chrome.d.ts +19 -9
- package/dist/site-chrome.js +23 -13
- package/dist/site-manifest.d.ts +25 -0
- package/dist/site-manifest.js +254 -0
- package/dist/site-roster.d.ts +41 -0
- package/dist/site-roster.js +149 -11
- package/dist/starter-grid.d.ts +232 -0
- package/dist/starter-grid.js +545 -0
- package/dist/surfaces.d.ts +41 -9
- package/dist/surfaces.js +268 -92
- package/dist/template-manifest.d.ts +65 -29
- package/dist/template-manifest.js +161 -382
- package/dist/template-names.d.ts +14 -12
- package/dist/template-names.js +13 -17
- package/package.json +5 -4
- package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
- package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
- package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
- package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
- package/site-templates/_base/content/en/GlossaryList/glossary.mdx +0 -1
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +0 -1
- package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
- package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
- package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +15 -0
- package/site-templates/_base/content/en/UseCase/persona-1.mdx +70 -0
- package/site-templates/_base/content/en/UseCase/persona-2.mdx +70 -0
- package/site-templates/_base/content/en/UseCase/persona-3.mdx +70 -0
- package/site-templates/{minimalism-site/TEMPLATE-MANIFEST.json → aliases/minimalism-site.TEMPLATE-MANIFEST.json} +72 -40
- package/site-templates/designs/minimalism/DESIGN.md +98 -6
- package/site-templates/designs/minimalism/sample/README.md +48 -0
- package/site-templates/designs/standard/DESIGN.md +103 -6
- package/site-templates/marketing-site/DESIGN.md +103 -6
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +144 -47
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +82 -41
- package/site-templates/marketing-site-free/DESIGN.md +103 -6
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +150 -48
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +114 -67
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
- package/site-templates/saas-product/DESIGN.md +103 -6
- package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
- package/site-templates/{simple-service → shapes/local-business-service}/TEMPLATE-MANIFEST.json +210 -81
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +356 -0
- package/site-templates/shapes/local-business-service/ailk-template.json +10 -0
- package/site-templates/{simple-service → shapes/local-business-service}/content/en/AboutPage/about.mdx +4 -4
- package/site-templates/shapes/local-business-service/content/en/HomePage/home.mdx +70 -0
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/local-business-service/content/en/PortfolioList/portfolio.mdx +171 -0
- package/site-templates/{minimalism-site → shapes/local-business-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +7 -8
- package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/ServicesList/services.mdx +61 -0
- package/site-templates/shapes/local-business-service/provenance.json +4 -0
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +471 -0
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +354 -0
- package/site-templates/shapes/organization-service/ailk-template.json +10 -0
- package/site-templates/shapes/organization-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/shapes/organization-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/organization-service/content/en/PortfolioList/portfolio.mdx +171 -0
- package/site-templates/shapes/organization-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
- package/site-templates/{minimalism-site → shapes/organization-service}/content/en/ServicesList/services.mdx +3 -4
- package/site-templates/shapes/organization-service/provenance.json +4 -0
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +478 -0
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +353 -0
- package/site-templates/shapes/person-service/ailk-template.json +10 -0
- package/site-templates/shapes/person-service/content/_site.mdx +85 -0
- package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
- package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +171 -0
- package/site-templates/{simple-service → shapes/person-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +10 -11
- package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
- package/site-templates/shapes/person-service/provenance.json +4 -0
- package/templates/.claude/rules/architecture.md +7 -0
- package/templates/.claude/skills/README.md +5 -3
- package/templates/.claude/skills/add-auth/references/env-vars.md +22 -15
- package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +3 -2
- package/templates/.claude/skills/add-page/SKILL.md +241 -238
- package/templates/.claude/skills/add-page/references/page-types.md +85 -72
- package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
- package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
- package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
- package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
- package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
- package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
- package/templates/.claude/skills/check-site/SKILL.md +23 -0
- package/templates/.claude/skills/fill-page/SKILL.md +26 -7
- package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
- package/templates/.claude/skills/setup-database/SKILL.md +156 -0
- package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
- package/templates/.claude/skills/setup-deploy/SKILL.md +64 -32
- package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +44 -36
- package/templates/.claude/skills/setup-deploy/references/runbook-template.md +58 -102
- package/templates/.claude/skills/update-page/SKILL.md +117 -0
- package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
- package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
- package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
- package/templates/.github/workflows/ci.yml +4 -0
- package/templates/.prettierignore +1 -0
- package/templates/CLAUDE.md +146 -103
- package/templates/DESIGN.md +103 -6
- package/templates/README.md +63 -43
- package/templates/apps/api/.env.example +2 -1
- package/templates/apps/api/CLAUDE.md +3 -2
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
- package/templates/apps/api/src/__tests__/server.test.ts +116 -0
- package/templates/apps/api/src/config/__tests__/index.test.ts +115 -14
- package/templates/apps/api/src/config/index.ts +35 -16
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
- package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
- package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
- package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
- package/templates/apps/api/src/lib/stripe.ts +9 -0
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
- package/templates/apps/api/src/middleware/auth.ts +54 -0
- package/templates/apps/api/src/openapi/spec.ts +63 -12
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
- package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
- package/templates/apps/api/src/routes/leads/index.ts +46 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
- package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
- package/templates/apps/api/src/server.ts +35 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
- package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
- package/templates/apps/api/src/services/lead-routing.ts +140 -30
- package/templates/apps/api/src/services/leads-inbox.ts +48 -11
- package/templates/apps/api/src/services/revalidation.ts +60 -15
- package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
- package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
- package/templates/apps/mcp/src/tools/create_project.ts +21 -6
- package/templates/apps/mcp/src/tools/purchase.ts +4 -2
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +22 -28
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
- package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +14 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +10 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +227 -84
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
- package/templates/apps/web/app/[locale]/contact/page.tsx +105 -15
- package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
- package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +21 -5
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
- package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +65 -17
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +106 -14
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
- package/templates/apps/web/app/[locale]/{book-a-call → portfolio}/page.tsx +12 -13
- package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
- package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
- package/templates/apps/web/app/api/revalidate/route.ts +78 -36
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
- package/templates/apps/web/lib/locale-guard.ts +18 -0
- package/templates/apps/web/lib/meet-placeholder.ts +5 -0
- package/templates/apps/web/lib/nav-config.ts +50 -37
- package/templates/apps/web/lib/site-brand.tsx +13 -9
- package/templates/apps/web/lib/site-identity.ts +14 -0
- package/templates/apps/web/lib/site-logo.tsx +36 -0
- package/templates/apps/web/lib/site-theme.ts +46 -26
- package/templates/apps/web/messages/en.json +45 -5
- package/templates/apps/web/middleware.ts +28 -4
- package/templates/content/_site.mdx +4 -2
- package/templates/content/en/BlogList/blog.mdx +37 -16
- package/templates/content/en/BookCall/book-a-call.mdx +80 -0
- package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
- package/templates/content/en/BookCall/onsite-service.mdx +56 -0
- package/templates/content/en/BookCall/start-project.mdx +56 -0
- package/templates/content/en/ContactPage/contact.mdx +43 -34
- package/templates/content/en/HomePage/home.mdx +10 -22
- package/templates/content/en/LandingPage/meet.mdx +59 -0
- package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
- package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
- package/templates/database/CHANGELOG.md +14 -0
- package/templates/database/package.json +1 -1
- package/templates/package.json +2 -1
- package/templates/tools/check-claude-md.ts +128 -0
- package/versions.json +33 -33
- package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -53
- package/site-templates/_base/content/en/UseCase/first-use-case.mdx +0 -23
- package/site-templates/_base/content/en/UseCase/second-use-case.mdx +0 -23
- package/site-templates/_base/content/en/UseCase/third-use-case.mdx +0 -23
- package/site-templates/minimalism-site/DESIGN.md +0 -162
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -470
- package/site-templates/minimalism-site/ailk-template.json +0 -23
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
- package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -53
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
- package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
- package/site-templates/minimalism-site/provenance.json +0 -4
- package/site-templates/simple-service/DESIGN.md +0 -162
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -333
- package/site-templates/simple-service/ailk-template.json +0 -11
- package/site-templates/simple-service/content/en/BookCall/book-call.mdx +0 -53
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -32
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
- package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
- package/site-templates/simple-service/provenance.json +0 -4
- package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
- package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -211
- package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
- package/templates/content/en/SoftwareProduct/product.mdx +0 -175
- /package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
- /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/first-engagement.mdx +0 -0
- /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/second-engagement.mdx +0 -0
- /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/third-engagement.mdx +0 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The Free /meet/<slug> placeholder detail route (#6897 D2). BookCall
|
|
2
|
+
// content served at /meet/<detail> by Next.js static-segment precedence:
|
|
3
|
+
// wherever apps/web/app/[locale]/meet/ exists (the scheduling module), that
|
|
4
|
+
// directory resolves every /meet/* URL first and this route never runs. On a
|
|
5
|
+
// keyless scaffold, this route serves the four Free placeholder detail pages.
|
|
6
|
+
//
|
|
7
|
+
// `slug` must equal MEET_PLACEHOLDER_HUB ("meet") — any other value 404s.
|
|
8
|
+
// This route deliberately serves ONLY the one meet hub, not a generic
|
|
9
|
+
// two-segment content route: BookCall's slug pattern forbids "/", so a
|
|
10
|
+
// generic join would also be reachable at a one-segment URL through the
|
|
11
|
+
// `[slug]` route — a duplicate-content defect. A second hub can generalize
|
|
12
|
+
// this route when one exists.
|
|
13
|
+
//
|
|
14
|
+
// Unknown detail slug → notFound() (renderPage's own detail-route branch: no
|
|
15
|
+
// translationsNamespace is passed, so a null source 404s rather than
|
|
16
|
+
// rendering an empty-state placeholder — the same contract `[slug]/page.tsx`
|
|
17
|
+
// documents).
|
|
18
|
+
//
|
|
19
|
+
// Deliberately NO generateStaticParams, for the same reason as `[slug]/page.tsx`:
|
|
20
|
+
// the locale layout reads cookies()/headers(), so this segment always renders
|
|
21
|
+
// on request.
|
|
22
|
+
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
23
|
+
import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
|
|
24
|
+
import type { Metadata } from "next";
|
|
25
|
+
import { notFound } from "next/navigation";
|
|
26
|
+
import type { ReactElement } from "react";
|
|
27
|
+
|
|
28
|
+
import { MEET_PLACEHOLDER_HUB } from "../../../../lib/meet-placeholder";
|
|
29
|
+
|
|
30
|
+
interface Props {
|
|
31
|
+
params: Promise<{ locale: string; slug: string; detail: string }>;
|
|
32
|
+
/** Test-only DI override for getMdxSource. Production callers leave undefined. */
|
|
33
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function generateMetadata({
|
|
37
|
+
params,
|
|
38
|
+
}: Pick<Props, "params">): Promise<Metadata> {
|
|
39
|
+
const { locale, slug, detail } = await params;
|
|
40
|
+
// Mirrors renderPageMetadata's own missing-frontmatter → {} contract: a
|
|
41
|
+
// hub mismatch is the same "nothing to title" case, not a thrown 404 —
|
|
42
|
+
// generateMetadata cannot itself change the response status.
|
|
43
|
+
if (slug !== MEET_PLACEHOLDER_HUB) return {};
|
|
44
|
+
return renderPageMetadata({ type: "BookCall", slug: detail, locale });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default async function Page({
|
|
48
|
+
params,
|
|
49
|
+
getMdxSource,
|
|
50
|
+
}: Props): Promise<ReactElement> {
|
|
51
|
+
const { locale, slug, detail } = await params;
|
|
52
|
+
if (slug !== MEET_PLACEHOLDER_HUB) notFound();
|
|
53
|
+
return renderPage({
|
|
54
|
+
type: "BookCall",
|
|
55
|
+
slug: detail,
|
|
56
|
+
locale,
|
|
57
|
+
populatedDescriptionTestId: `${detail}-description`,
|
|
58
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// `ItemList` — the correct page type, not a LandingPage wearing a `schema:`
|
|
22
22
|
// override the structured-data gate rejects) served here, and still yields
|
|
23
23
|
// to the module's directory by the same precedence. Every other list type
|
|
24
|
-
// has its own route directory (`/features` → ProductsList, `/
|
|
24
|
+
// has its own route directory (`/features` → ProductsList, `/for` →
|
|
25
25
|
// UseCasesList) and never reaches this route.
|
|
26
26
|
//
|
|
27
27
|
// Unknown slug → `notFound()` (renderPage's own detail-route branch: no
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for /alternatives (#6878 D9) — the ComparisonsList hub.
|
|
3
|
+
*
|
|
4
|
+
* `renderPage`/`renderPageMetadata`/`getMdxSlugs` are mocked at the
|
|
5
|
+
* `@working-theory/page-renderer` boundary so this suite can assert on the
|
|
6
|
+
* exact input `renderPage` receives — in particular the injected
|
|
7
|
+
* `getPageSections` override that appends the generated spoke list (AC-7:
|
|
8
|
+
* "the hub links each /vs spoke"). `getMdxSource` and `getPageSections`
|
|
9
|
+
* (the hub's own declared sections) are supplied per-call through the
|
|
10
|
+
* route's DI props, as every other page in this app does.
|
|
11
|
+
*/
|
|
12
|
+
// The mock return value is never rendered — every assertion below inspects
|
|
13
|
+
// `mockRenderPage.mock.calls` directly — so it carries no `ReactElement`
|
|
14
|
+
// cast; `renderPage`'s real return type is `Promise<ReactElement>` in
|
|
15
|
+
// production, unaffected by this mock's loose type here.
|
|
16
|
+
const mockRenderPage = jest.fn(async (..._args: unknown[]) => "<rendered/>");
|
|
17
|
+
const mockRenderPageMetadata = jest.fn((..._args: unknown[]) => undefined);
|
|
18
|
+
const mockGetMdxSlugs = jest.fn((..._args: unknown[]) => Promise.resolve<string[]>([]));
|
|
19
|
+
const mockNotFound = jest.fn(() => {
|
|
20
|
+
throw new Error("NEXT_NOT_FOUND");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
jest.mock("next/navigation", () => ({
|
|
24
|
+
__esModule: true,
|
|
25
|
+
notFound: () => mockNotFound(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
jest.mock("@working-theory/page-renderer", () => ({
|
|
29
|
+
__esModule: true,
|
|
30
|
+
getMdxSlugs: (...args: unknown[]) => mockGetMdxSlugs(...args),
|
|
31
|
+
renderPage: (...args: unknown[]) => mockRenderPage(...args),
|
|
32
|
+
renderPageMetadata: (...args: unknown[]) => mockRenderPageMetadata(...args),
|
|
33
|
+
// Never reached — every call in this suite passes both via DI props.
|
|
34
|
+
getMdxSource: jest.fn(),
|
|
35
|
+
getPageSections: jest.fn(),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
import AlternativesPage, { generateMetadata } from "../page";
|
|
39
|
+
|
|
40
|
+
const HUB_SOURCE = {
|
|
41
|
+
frontmatter: {
|
|
42
|
+
type: "ComparisonsList",
|
|
43
|
+
slug: "alternatives",
|
|
44
|
+
title: "Alternatives",
|
|
45
|
+
description: "How we compare.",
|
|
46
|
+
},
|
|
47
|
+
body: "",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
beforeEach(() => jest.clearAllMocks());
|
|
51
|
+
|
|
52
|
+
describe("AlternativesPage — hub seed absent", () => {
|
|
53
|
+
it("calls notFound() and never reaches renderPage", async () => {
|
|
54
|
+
const getMdxSource = jest.fn().mockResolvedValue(null);
|
|
55
|
+
|
|
56
|
+
await expect(
|
|
57
|
+
AlternativesPage({
|
|
58
|
+
params: Promise.resolve({ locale: "en" }),
|
|
59
|
+
getMdxSource,
|
|
60
|
+
getPageSections: jest.fn(),
|
|
61
|
+
}),
|
|
62
|
+
).rejects.toThrow("NEXT_NOT_FOUND");
|
|
63
|
+
|
|
64
|
+
expect(mockNotFound).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(mockRenderPage).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("AlternativesPage — metadata", () => {
|
|
70
|
+
it("resolves ComparisonsList slug alternatives", async () => {
|
|
71
|
+
await generateMetadata({ params: Promise.resolve({ locale: "en" }) });
|
|
72
|
+
expect(mockRenderPageMetadata).toHaveBeenCalledWith({
|
|
73
|
+
type: "ComparisonsList",
|
|
74
|
+
slug: "alternatives",
|
|
75
|
+
locale: "en",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("AlternativesPage — generated spoke list (D9)", () => {
|
|
81
|
+
it("appends one items section entry per ComparisonPage spoke, each linking /vs/<slug>", async () => {
|
|
82
|
+
mockGetMdxSlugs.mockResolvedValue(["acme", "globex"]);
|
|
83
|
+
const spokeSources: Record<string, unknown> = {
|
|
84
|
+
acme: {
|
|
85
|
+
frontmatter: { title: "vs. Acme", description: "How we compare to Acme." },
|
|
86
|
+
body: "",
|
|
87
|
+
},
|
|
88
|
+
globex: {
|
|
89
|
+
frontmatter: { title: "vs. Globex", description: "How we compare to Globex." },
|
|
90
|
+
body: "",
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
const getMdxSource = jest
|
|
94
|
+
.fn()
|
|
95
|
+
.mockImplementation((type: string, slug: string) => {
|
|
96
|
+
if (type === "ComparisonsList") return Promise.resolve(HUB_SOURCE);
|
|
97
|
+
return Promise.resolve(spokeSources[slug] ?? null);
|
|
98
|
+
});
|
|
99
|
+
const getPageSections = jest
|
|
100
|
+
.fn()
|
|
101
|
+
.mockResolvedValue([{ id: "hero", type: "hero", data: {} }]);
|
|
102
|
+
|
|
103
|
+
await AlternativesPage({
|
|
104
|
+
params: Promise.resolve({ locale: "en" }),
|
|
105
|
+
getMdxSource,
|
|
106
|
+
getPageSections,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(mockRenderPage).toHaveBeenCalledTimes(1);
|
|
110
|
+
const passedInput = mockRenderPage.mock.calls[0]?.[0] as {
|
|
111
|
+
type: string;
|
|
112
|
+
slug: string;
|
|
113
|
+
getPageSections: (frontmatter: unknown) => Promise<unknown[]>;
|
|
114
|
+
};
|
|
115
|
+
expect(passedInput.type).toBe("ComparisonsList");
|
|
116
|
+
expect(passedInput.slug).toBe("alternatives");
|
|
117
|
+
|
|
118
|
+
const sections = await passedInput.getPageSections({});
|
|
119
|
+
expect(sections).toEqual([
|
|
120
|
+
{ id: "hero", type: "hero", data: {} },
|
|
121
|
+
{
|
|
122
|
+
type: "items",
|
|
123
|
+
id: "comparison-spokes",
|
|
124
|
+
data: {
|
|
125
|
+
items: [
|
|
126
|
+
{
|
|
127
|
+
title: "vs. Acme",
|
|
128
|
+
description: "How we compare to Acme.",
|
|
129
|
+
link: { label: "Read the comparison", href: "/vs/acme" },
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
title: "vs. Globex",
|
|
133
|
+
description: "How we compare to Globex.",
|
|
134
|
+
link: { label: "Read the comparison", href: "/vs/globex" },
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("omits the generated section entirely with zero spokes (the hub's own sections render alone)", async () => {
|
|
143
|
+
mockGetMdxSlugs.mockResolvedValue([]);
|
|
144
|
+
const getMdxSource = jest.fn().mockResolvedValue(HUB_SOURCE);
|
|
145
|
+
const getPageSections = jest
|
|
146
|
+
.fn()
|
|
147
|
+
.mockResolvedValue([{ id: "hero", type: "hero", data: {} }]);
|
|
148
|
+
|
|
149
|
+
await AlternativesPage({
|
|
150
|
+
params: Promise.resolve({ locale: "en" }),
|
|
151
|
+
getMdxSource,
|
|
152
|
+
getPageSections,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const passedInput = mockRenderPage.mock.calls[0]?.[0] as {
|
|
156
|
+
getPageSections: (frontmatter: unknown) => Promise<unknown[]>;
|
|
157
|
+
};
|
|
158
|
+
const sections = await passedInput.getPageSections({});
|
|
159
|
+
expect(sections).toEqual([{ id: "hero", type: "hero", data: {} }]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// ComparisonsList hub (#6878 D9) — the /alternatives page. Reads
|
|
2
|
+
// ComparisonsList MDX via the content adapter, injects a generated `items`
|
|
3
|
+
// section listing every ComparisonPage spoke (so the visible card grid and
|
|
4
|
+
// the CollectionPage's ItemList JSON-LD — built from the same sections —
|
|
5
|
+
// always agree, D9), and emits via the dispatcher.
|
|
6
|
+
//
|
|
7
|
+
// notFound() when the hub seed is absent, so no empty hub is ever served
|
|
8
|
+
// (unlike the /for hub's empty-state branch): an add-page run always writes
|
|
9
|
+
// the seed alongside the first spoke (D11), so a fresh scaffold — which
|
|
10
|
+
// excludes this gated surface entirely — never reaches this branch in
|
|
11
|
+
// production, only a mis-authored site would.
|
|
12
|
+
//
|
|
13
|
+
// Gated off by default (surfaces.ts `alternatives` surface): no roster names
|
|
14
|
+
// this route, so a fresh scaffold excludes it.
|
|
15
|
+
import type {
|
|
16
|
+
getMdxSource as defaultGetMdxSource,
|
|
17
|
+
getPageSections as defaultGetPageSections,
|
|
18
|
+
} from "@working-theory/page-renderer";
|
|
19
|
+
import {
|
|
20
|
+
getMdxSlugs,
|
|
21
|
+
getMdxSource as loadMdxSource,
|
|
22
|
+
getPageSections as loadPageSections,
|
|
23
|
+
renderPage,
|
|
24
|
+
renderPageMetadata,
|
|
25
|
+
} from "@working-theory/page-renderer";
|
|
26
|
+
import type { Metadata } from "next";
|
|
27
|
+
import { notFound } from "next/navigation";
|
|
28
|
+
import type { ReactElement } from "react";
|
|
29
|
+
|
|
30
|
+
interface Props {
|
|
31
|
+
params: Promise<{ locale: string }>;
|
|
32
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
33
|
+
getPageSections?: typeof defaultGetPageSections;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
|
|
37
|
+
|
|
38
|
+
export async function generateMetadata({
|
|
39
|
+
params,
|
|
40
|
+
}: Pick<Props, "params">): Promise<Metadata> {
|
|
41
|
+
const { locale } = await params;
|
|
42
|
+
return renderPageMetadata({
|
|
43
|
+
type: "ComparisonsList",
|
|
44
|
+
slug: "alternatives",
|
|
45
|
+
locale,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default async function Page({
|
|
50
|
+
params,
|
|
51
|
+
getMdxSource,
|
|
52
|
+
getPageSections,
|
|
53
|
+
}: Props): Promise<ReactElement> {
|
|
54
|
+
const { locale } = await params;
|
|
55
|
+
const load = getMdxSource ?? loadMdxSource;
|
|
56
|
+
const loadSections = getPageSections ?? loadPageSections;
|
|
57
|
+
|
|
58
|
+
const source = await load("ComparisonsList", "alternatives", locale);
|
|
59
|
+
if (source === null) {
|
|
60
|
+
notFound();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Append one generated `items` section listing every ComparisonPage spoke
|
|
65
|
+
* — D9's "the visible list and the CollectionPage's ItemList always
|
|
66
|
+
* agree" property, since `renderPage` builds JSON-LD from these same
|
|
67
|
+
* sections. Absent when there are zero spokes: the hub still renders its
|
|
68
|
+
* own authored sections and answers 200 on its seed alone.
|
|
69
|
+
*/
|
|
70
|
+
async function withSpokes(frontmatter: unknown): Promise<Section[]> {
|
|
71
|
+
const declared = await loadSections(frontmatter);
|
|
72
|
+
const slugs = await getMdxSlugs("ComparisonPage", locale);
|
|
73
|
+
if (slugs.length === 0) return declared;
|
|
74
|
+
|
|
75
|
+
const items = await Promise.all(
|
|
76
|
+
slugs.map(async (slug) => {
|
|
77
|
+
const spoke = await load("ComparisonPage", slug, locale);
|
|
78
|
+
const fm = (spoke?.frontmatter ?? {}) as {
|
|
79
|
+
title?: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
title: fm.title ?? slug,
|
|
84
|
+
description: fm.description ?? "",
|
|
85
|
+
link: { label: "Read the comparison", href: `/vs/${slug}` },
|
|
86
|
+
};
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return [
|
|
91
|
+
...declared,
|
|
92
|
+
{ type: "items" as const, id: "comparison-spokes", data: { items } },
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return renderPage({
|
|
97
|
+
type: "ComparisonsList",
|
|
98
|
+
slug: "alternatives",
|
|
99
|
+
locale,
|
|
100
|
+
translationsNamespace: "alternatives",
|
|
101
|
+
getPageSections: withSpokes,
|
|
102
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* conversation logic, only:
|
|
9
9
|
*
|
|
10
10
|
* 1. Submission — `onComplete` hands the finished answers to the SAME
|
|
11
|
-
* `captureLeadAction` (`./contact.actions.ts`) the
|
|
12
|
-
* already uses, built as a `FormData` (imperatively, not a real
|
|
11
|
+
* `captureLeadAction` (`./contact.actions.ts`) the shared
|
|
12
|
+
* `LeadCaptureForm` on `/contact` already uses, built as a `FormData` (imperatively, not a real
|
|
13
13
|
* `<form>` submit — React 19 Server Actions are plain async functions).
|
|
14
14
|
* That action posts through the EXISTING `POST /v1/leads/submit` route
|
|
15
15
|
* (`apps/api/src/routes/leads`, consumed via `@working-theory/api-client`,
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
* second web surface, not a new one. `contact.actions.ts`'s own
|
|
22
22
|
* doc-comment covers the additive `source` FormData field this file
|
|
23
23
|
* sets to distinguish intake-originated leads.
|
|
24
|
-
* 2. Skip fallback — `onSkip` swaps to the
|
|
24
|
+
* 2. Skip fallback — `onSkip` swaps to the shared `LeadCaptureForm` block
|
|
25
|
+
* (#6730 — the one contact form), bound to the same action,
|
|
25
26
|
* carrying over whatever answers were already captured as its
|
|
26
27
|
* `initialValues` prefill. A failed auto-submit on `onComplete` falls
|
|
27
28
|
* back the same way, so a lead is never silently lost.
|
|
@@ -43,14 +44,16 @@ import {
|
|
|
43
44
|
ConversationalIntake,
|
|
44
45
|
type ConversationalIntakeProps,
|
|
45
46
|
InlineMessage,
|
|
47
|
+
LeadCaptureForm,
|
|
48
|
+
localeHref,
|
|
46
49
|
THINKING_DELAY_MAX_MS,
|
|
47
50
|
THINKING_DELAY_MIN_MS,
|
|
48
51
|
} from "@working-theory/ui";
|
|
49
52
|
import { useLocale, useTranslations } from "next-intl";
|
|
50
53
|
import { useCallback, useMemo, useRef, useState } from "react";
|
|
51
54
|
|
|
55
|
+
import { contactFormText } from "./contact-form-text";
|
|
52
56
|
import { captureLeadAction } from "./contact.actions";
|
|
53
|
-
import { ContactForm, type ContactFormInitialValues } from "./ContactForm";
|
|
54
57
|
import { recordIntakeTurn } from "./intake-analytics";
|
|
55
58
|
import { buildContactIntakeScript } from "./intake-script";
|
|
56
59
|
|
|
@@ -120,7 +123,7 @@ export function ConversationalIntakeWrapper({
|
|
|
120
123
|
);
|
|
121
124
|
|
|
122
125
|
const [surface, setSurface] = useState<Surface>("intake");
|
|
123
|
-
const [initialValues, setInitialValues] = useState<
|
|
126
|
+
const [initialValues, setInitialValues] = useState<Record<string, string>>(
|
|
124
127
|
{},
|
|
125
128
|
);
|
|
126
129
|
const [submitError, setSubmitError] = useState<string | undefined>(
|
|
@@ -193,7 +196,12 @@ export function ConversationalIntakeWrapper({
|
|
|
193
196
|
{submitError}
|
|
194
197
|
</InlineMessage>
|
|
195
198
|
) : null}
|
|
196
|
-
<
|
|
199
|
+
<LeadCaptureForm
|
|
200
|
+
action={captureLeadAction}
|
|
201
|
+
text={contactFormText(t)}
|
|
202
|
+
termsHref={localeHref(locale, "/terms")}
|
|
203
|
+
initialValues={initialValues}
|
|
204
|
+
/>
|
|
197
205
|
</Container>
|
|
198
206
|
);
|
|
199
207
|
}
|
package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* the EXISTING lead-capture endpoint. Only the outermost network
|
|
7
7
|
* boundary is mocked (`@working-theory/api-client`'s `leads.submit`) —
|
|
8
8
|
* the REAL `ConversationalIntake` block (#4543) and its engine (#4532),
|
|
9
|
-
* the REAL `captureLeadAction` (the SAME action
|
|
10
|
-
* uses), and the REAL `submitLeadInputSchema` validation all run, proving
|
|
9
|
+
* the REAL `captureLeadAction` (the SAME action the shared
|
|
10
|
+
* `LeadCaptureForm` on `/contact` uses), and the REAL `submitLeadInputSchema` validation all run, proving
|
|
11
11
|
* the whole chain end to end. No file under apps/api is imported, mocked,
|
|
12
12
|
* or touched by this test or by the wiring it exercises.
|
|
13
13
|
* AC2 — Skip renders the existing form, carrying over answers already
|
|
@@ -36,13 +36,14 @@ jest.mock("@working-theory/analytics/server", () => ({
|
|
|
36
36
|
}));
|
|
37
37
|
|
|
38
38
|
// ConversationalIntakeWrapper's own responsibility under test here is the
|
|
39
|
-
// submission/skip/analytics wiring around the block — not
|
|
40
|
-
// rendering, which
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
jest.mock("
|
|
45
|
-
|
|
39
|
+
// submission/skip/analytics wiring around the block — not the shared
|
|
40
|
+
// LeadCaptureForm's own rendering, which its block suite covers
|
|
41
|
+
// (packages/ui/src/blocks/__tests__/LeadCaptureForm.test.tsx). The real
|
|
42
|
+
// `@working-theory/ui` runs (the ConversationalIntake block included); only
|
|
43
|
+
// the form block is stubbed, so the carried-over `initialValues` can be read.
|
|
44
|
+
jest.mock("@working-theory/ui", () => ({
|
|
45
|
+
...jest.requireActual("@working-theory/ui"),
|
|
46
|
+
LeadCaptureForm: (props: { initialValues?: Record<string, string> }) =>
|
|
46
47
|
require("react").createElement("div", {
|
|
47
48
|
"data-testid": "contact-form-stub",
|
|
48
49
|
"data-initial-values": JSON.stringify(props.initialValues ?? {}),
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contactFormText (#6730 D8) — the `contact` namespace mapped onto the shared
|
|
3
|
+
* form block's copy, with `unavailableBody`'s `{email}` placeholder kept for
|
|
4
|
+
* the block to fill.
|
|
5
|
+
*/
|
|
6
|
+
import messages from "../../../../messages/en.json";
|
|
7
|
+
import { contactFormText } from "../contact-form-text";
|
|
8
|
+
|
|
9
|
+
/** A translator over the English catalog that interpolates `{name}` values, as ICU does. */
|
|
10
|
+
function translator(key: string, values?: Record<string, string>): string {
|
|
11
|
+
const template = (messages.contact as Record<string, unknown>)[key];
|
|
12
|
+
if (typeof template !== "string") throw new Error(`missing contact.${key}`);
|
|
13
|
+
return template.replace(/\{(\w+)\}/g, (m, name: string) => values?.[name] ?? m);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("contactFormText", () => {
|
|
17
|
+
it("maps every block string from the English catalog, keeping {email} for the block", () => {
|
|
18
|
+
expect(contactFormText(translator)).toEqual({
|
|
19
|
+
nameLabel: "Name",
|
|
20
|
+
emailLabel: "Email",
|
|
21
|
+
messageLabel: "Message",
|
|
22
|
+
submit: "Send message",
|
|
23
|
+
sending: "Sending…",
|
|
24
|
+
success: messages.contact.successMessage,
|
|
25
|
+
genericError: messages.contact.genericError,
|
|
26
|
+
consentStatement: messages.contact.consentStatement,
|
|
27
|
+
unavailableHeading: messages.contact.unavailableHeading,
|
|
28
|
+
unavailableBody: "Email us at {email} and we'll reply.",
|
|
29
|
+
unavailableNoEmail: messages.contact.unavailableNoEmail,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -179,3 +179,132 @@ describe("captureLeadAction — API error", () => {
|
|
|
179
179
|
consoleErrorSpy.mockRestore();
|
|
180
180
|
});
|
|
181
181
|
});
|
|
182
|
+
|
|
183
|
+
// ─── #6730 D8 — honest outcomes and declared extra fields ────────────────────
|
|
184
|
+
|
|
185
|
+
describe("captureLeadAction — outcome mapping (#6730)", () => {
|
|
186
|
+
it("503 lead_capture_not_configured → unavailable", async () => {
|
|
187
|
+
mockSubmit.mockResolvedValueOnce({
|
|
188
|
+
ok: false,
|
|
189
|
+
status: 503,
|
|
190
|
+
body: { error: "lead_capture_not_configured", message: "Lead capture is not configured." },
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
194
|
+
|
|
195
|
+
expect(result).toEqual({ kind: "unavailable" });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("503 delivery_failed → the generic error, never success", async () => {
|
|
199
|
+
mockSubmit.mockResolvedValueOnce({
|
|
200
|
+
ok: false,
|
|
201
|
+
status: 503,
|
|
202
|
+
body: { error: "delivery_failed", message: "The message could not be delivered." },
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
206
|
+
|
|
207
|
+
expect(result).toMatchObject({ kind: "error" });
|
|
208
|
+
expect(result).not.toMatchObject({ kind: "success" });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it.each([400, 429, 500, 503])("a non-2xx (%s) is never success", async (status) => {
|
|
212
|
+
mockSubmit.mockResolvedValueOnce({ ok: false, status, body: { error: "x" } });
|
|
213
|
+
|
|
214
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
215
|
+
|
|
216
|
+
expect(result?.kind).toBe("error");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("201 → success", async () => {
|
|
220
|
+
mockSubmit.mockResolvedValueOnce({
|
|
221
|
+
ok: true,
|
|
222
|
+
body: { id: "email-1-abcdef12", persisted: "email" },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
226
|
+
|
|
227
|
+
expect(result).toEqual({ kind: "success" });
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("captureLeadAction — declared extra fields (#6730 D8a)", () => {
|
|
232
|
+
it("carries an extra string field (e.g. a select) into payload", async () => {
|
|
233
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
234
|
+
|
|
235
|
+
await captureLeadAction(INITIAL_STATE, makeFormData({ ...VALID_FIELDS, plan: "pro" }));
|
|
236
|
+
|
|
237
|
+
const body = mockSubmit.mock.calls[0][0] as { payload: Record<string, unknown> };
|
|
238
|
+
expect(body.payload.plan).toBe("pro");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("never copies reserved or Next-internal fields into payload", async () => {
|
|
242
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
243
|
+
|
|
244
|
+
await captureLeadAction(
|
|
245
|
+
INITIAL_STATE,
|
|
246
|
+
makeFormData({
|
|
247
|
+
...VALID_FIELDS,
|
|
248
|
+
consent: "true",
|
|
249
|
+
ailk_hp: "",
|
|
250
|
+
source: "conversational-intake",
|
|
251
|
+
$ACTION_ID_abc: "",
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const body = mockSubmit.mock.calls[0][0] as {
|
|
256
|
+
source: string;
|
|
257
|
+
payload: Record<string, unknown>;
|
|
258
|
+
};
|
|
259
|
+
expect(Object.keys(body.payload).sort()).toEqual(["message", "name"]);
|
|
260
|
+
expect(body.source).toBe("conversational-intake");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("more than 20 extra keys → a form error, no API call", async () => {
|
|
264
|
+
const extras = Object.fromEntries(
|
|
265
|
+
Array.from({ length: 21 }, (_, i) => [`extra${i}`, "x"]),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const result = await captureLeadAction(
|
|
269
|
+
INITIAL_STATE,
|
|
270
|
+
makeFormData({ ...VALID_FIELDS, ...extras }),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(result).toMatchObject({ kind: "error" });
|
|
274
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("an extra value over 2000 characters → a form error, no API call", async () => {
|
|
278
|
+
const result = await captureLeadAction(
|
|
279
|
+
INITIAL_STATE,
|
|
280
|
+
makeFormData({ ...VALID_FIELDS, notes: "x".repeat(2001) }),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
expect(result).toMatchObject({ kind: "error" });
|
|
284
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("captureLeadAction — honeypot (#6730)", () => {
|
|
289
|
+
it("a filled honeypot → success, and nothing is sent", async () => {
|
|
290
|
+
const result = await captureLeadAction(
|
|
291
|
+
INITIAL_STATE,
|
|
292
|
+
makeFormData({ ...VALID_FIELDS, ailk_hp: "https://spam.example" }),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
expect(result).toEqual({ kind: "success" });
|
|
296
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("a declared `website` field is an ordinary field, carried into payload", async () => {
|
|
300
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
301
|
+
|
|
302
|
+
await captureLeadAction(
|
|
303
|
+
INITIAL_STATE,
|
|
304
|
+
makeFormData({ ...VALID_FIELDS, website: "https://acme.example" }),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const body = mockSubmit.mock.calls[0][0] as { payload: Record<string, unknown> };
|
|
308
|
+
expect(body.payload.website).toBe("https://acme.example");
|
|
309
|
+
});
|
|
310
|
+
});
|