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
|
@@ -27,7 +27,7 @@ import { Telemetry, VerificationMeta } from "@working-theory/analytics";
|
|
|
27
27
|
import { MarketingShell } from "@working-theory/templates/shells";
|
|
28
28
|
import { themeToCssVarsBothModes } from "@working-theory/theme";
|
|
29
29
|
import { Link, isRtl, localeHref } from "@working-theory/ui";
|
|
30
|
-
import { LocaleSwitcherNav } from "@working-theory/ui/blocks";
|
|
30
|
+
import { BRAND_PATHS, LocaleSwitcherNav } from "@working-theory/ui/blocks";
|
|
31
31
|
import {
|
|
32
32
|
Banner,
|
|
33
33
|
Footer,
|
|
@@ -50,10 +50,12 @@ import { auth } from "../../auth";
|
|
|
50
50
|
import { GoogleAnalytics } from "../../components/google-analytics";
|
|
51
51
|
import { SignOutTrigger } from "../../components/sign-out-trigger";
|
|
52
52
|
import { isAuthPath } from "../../lib/auth-routes";
|
|
53
|
+
import { assertEnabledLocale } from "../../lib/locale-guard";
|
|
53
54
|
import { footerColumns, navItems } from "../../lib/nav-config";
|
|
54
55
|
import { getOgImageUrl } from "../../lib/og";
|
|
55
|
-
import { SITE_NAME
|
|
56
|
+
import { SITE_NAME } from "../../lib/site-brand";
|
|
56
57
|
import { siteChrome } from "../../lib/site-chrome";
|
|
58
|
+
import { siteLogo } from "../../lib/site-logo";
|
|
57
59
|
// Registers the `_site.mdx` identity with page-renderer at module load, so
|
|
58
60
|
// every page rendered under this layout carries the sitewide JSON-LD (#6597).
|
|
59
61
|
import "../../lib/site-identity";
|
|
@@ -211,6 +213,9 @@ export default async function LocaleLayout({
|
|
|
211
213
|
params,
|
|
212
214
|
}: Props): Promise<ReactElement> {
|
|
213
215
|
const { locale } = await params;
|
|
216
|
+
// #6107 — a segment that is not an enabled locale is a 404, before any
|
|
217
|
+
// message load or session read.
|
|
218
|
+
assertEnabledLocale(locale);
|
|
214
219
|
const messages = await getMessages();
|
|
215
220
|
const themeMode = await resolveThemeMode();
|
|
216
221
|
const consentDecided = await hasConsentChoice();
|
|
@@ -227,6 +232,7 @@ export default async function LocaleLayout({
|
|
|
227
232
|
// The Free marketing-site chrome (#5469 D5): the Product flyout's three
|
|
228
233
|
// entries + descriptions, the top-level items, and the Get started CTA.
|
|
229
234
|
product: tNav("product"),
|
|
235
|
+
services: tNav("services"),
|
|
230
236
|
useCases: tNav("useCases"),
|
|
231
237
|
useCasesDesc: tNav("useCasesDesc"),
|
|
232
238
|
footerSitePagesHeading: tNav("footerSitePagesHeading"),
|
|
@@ -238,6 +244,20 @@ export default async function LocaleLayout({
|
|
|
238
244
|
feature4: tNav("feature4"),
|
|
239
245
|
feature5: tNav("feature5"),
|
|
240
246
|
allFeatures: tNav("allFeatures"),
|
|
247
|
+
// #6878 D6 — one flyout rule for every shape: the offering column
|
|
248
|
+
// (Features or Services) beside the Customers column (the /for
|
|
249
|
+
// persona pages), plus the Customers footer column.
|
|
250
|
+
flyoutFeaturesHeading: tNav("flyoutFeaturesHeading"),
|
|
251
|
+
flyoutServicesHeading: tNav("flyoutServicesHeading"),
|
|
252
|
+
flyoutCustomersHeading: tNav("flyoutCustomersHeading"),
|
|
253
|
+
footerCustomersHeading: tNav("footerCustomersHeading"),
|
|
254
|
+
allCustomers: tNav("allCustomers"),
|
|
255
|
+
customer1: tNav("customer1"),
|
|
256
|
+
customer2: tNav("customer2"),
|
|
257
|
+
customer3: tNav("customer3"),
|
|
258
|
+
service1: tNav("service1"),
|
|
259
|
+
service2: tNav("service2"),
|
|
260
|
+
service3: tNav("service3"),
|
|
241
261
|
howItWorks: tNav("howItWorks"),
|
|
242
262
|
howItWorksDesc: tNav("howItWorksDesc"),
|
|
243
263
|
features: tNav("features"),
|
|
@@ -245,6 +265,7 @@ export default async function LocaleLayout({
|
|
|
245
265
|
faq: tNav("faq"),
|
|
246
266
|
faqDesc: tNav("faqDesc"),
|
|
247
267
|
pricing: tNav("pricing"),
|
|
268
|
+
portfolio: tNav("portfolio"),
|
|
248
269
|
about: tNav("about"),
|
|
249
270
|
// The footer's column groups (#6468): their two headings + Contact.
|
|
250
271
|
contact: tNav("contact"),
|
|
@@ -284,10 +305,13 @@ export default async function LocaleLayout({
|
|
|
284
305
|
// enforcement, the callbacks are the hook for @working-theory/consent.
|
|
285
306
|
const tConsent = await getTranslations({ locale, namespace: "consent" });
|
|
286
307
|
|
|
287
|
-
// Right-side action cluster (#4984). Signed out → the
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
308
|
+
// Right-side action cluster (#4984). Signed out → the auth pair: an
|
|
309
|
+
// outline-strong "Sign in" → /sign-in beside a primary "Sign up" → /sign-up
|
|
310
|
+
// (the operator's auth-pair idiom, #4370). Every shape ships the (auth)
|
|
311
|
+
// route group, so both routes exist in every scaffold; whether the header
|
|
312
|
+
// shows them is the design's nav setting (#6797 — an earlier revision of
|
|
313
|
+
// this comment said the Free site had no sign-up flow; it renders both and
|
|
314
|
+
// the service shapes now roster both). Signed in → the user's name (a
|
|
291
315
|
// non-navigating label) + an outline-strong "Sign out"; both authored as
|
|
292
316
|
// `kind: "flow"` so Nav hands their triggers back through
|
|
293
317
|
// `renderFlowTrigger` (see NAV_CLIENT_ACTION_FLOW above).
|
|
@@ -319,7 +343,7 @@ export default async function LocaleLayout({
|
|
|
319
343
|
const hasFlowAction = navActions.some((action) => action.kind === "flow");
|
|
320
344
|
|
|
321
345
|
// Shared brand mark injected into both Nav and Footer chrome slots. The
|
|
322
|
-
// mark comes from site-
|
|
346
|
+
// mark comes from site-logo.tsx (the mark the /sign-in card also renders);
|
|
323
347
|
// both add the shipped wordmark beside it — the Nav composes mark + wordmark
|
|
324
348
|
// on desktop and mark-only below `md` (#5469 D5), the columns Footer shows
|
|
325
349
|
// the full lockup at every width (#6507).
|
|
@@ -364,11 +388,10 @@ export default async function LocaleLayout({
|
|
|
364
388
|
// `(marketing)` group — a ~35-directory move for a five-page delta (spec D1).
|
|
365
389
|
const isAuthRoute = isAuthPath(pathname, enabledLocales);
|
|
366
390
|
|
|
367
|
-
// Placeholder social handles
|
|
368
|
-
//
|
|
391
|
+
// Placeholder social handles (#5469 D5): `{{COMPANY_HANDLE}}` is a
|
|
392
|
+
// create-ailk marker substituted at scaffold time
|
|
369
393
|
// (packages/create-ailk/src/programmatic.ts), so a customer's site never
|
|
370
|
-
// carries the kit vendor's own identity here.
|
|
371
|
-
// typed, so the placeholder does not go stale.
|
|
394
|
+
// carries the kit vendor's own identity here.
|
|
372
395
|
const socialLinks = [
|
|
373
396
|
{ platform: "facebook", href: "https://facebook.com/{{COMPANY_HANDLE}}" },
|
|
374
397
|
{ platform: "instagram", href: "https://instagram.com/{{COMPANY_HANDLE}}" },
|
|
@@ -378,6 +401,9 @@ export default async function LocaleLayout({
|
|
|
378
401
|
href: "https://linkedin.com/company/{{COMPANY_HANDLE}}",
|
|
379
402
|
},
|
|
380
403
|
{ platform: "youtube", href: "https://youtube.com/@{{COMPANY_HANDLE}}" },
|
|
404
|
+
// #6739 — the kit's own repo, operator-directed: the Free template links
|
|
405
|
+
// back to where it came from.
|
|
406
|
+
{ platform: "github", href: "https://github.com/working-theory-labs/ai-launch-kit" },
|
|
381
407
|
] as const;
|
|
382
408
|
// Footer column groups (#6468), generated beside `navItems` as label keys;
|
|
383
409
|
// `Footer` takes translated strings, so resolve them through the same map.
|
|
@@ -389,8 +415,9 @@ export default async function LocaleLayout({
|
|
|
389
415
|
items: col.items.map((item) => ({ ...item, label: labelFor(item.labelKey) })),
|
|
390
416
|
}));
|
|
391
417
|
|
|
392
|
-
//
|
|
393
|
-
|
|
418
|
+
// The site's own name (#6942) — `SITE_NAME` resolves from `_site.mdx`'s
|
|
419
|
+
// `siteName:`, so a customer's footer never carries the kit vendor's name.
|
|
420
|
+
const copyrightText = `© ${new Date().getFullYear()} ${SITE_NAME}. All rights reserved.`;
|
|
394
421
|
|
|
395
422
|
// Footer legal links (#5612 D3). The centered variant folds in FAQ — the
|
|
396
423
|
// roster's third always-scaffolded core route (`/faq`, alongside `/privacy`
|
|
@@ -400,6 +427,10 @@ export default async function LocaleLayout({
|
|
|
400
427
|
const legalLinks = [
|
|
401
428
|
{ label: "Privacy Policy", href: "/privacy" },
|
|
402
429
|
{ label: "Terms of Service", href: "/terms" },
|
|
430
|
+
// #6739 — a link, not the reopen button: the privacy page's
|
|
431
|
+
// #cookie-settings anchor explains the choice, and arriving there
|
|
432
|
+
// reopens the consent banner (BannerConsentIsland).
|
|
433
|
+
{ label: tConsent("settingsLabel"), href: "/privacy#cookie-settings" },
|
|
403
434
|
...(siteChrome.footer === "centered"
|
|
404
435
|
? [{ label: navLabels.items.faq, href: "/faq" }]
|
|
405
436
|
: []),
|
|
@@ -515,11 +546,29 @@ export default async function LocaleLayout({
|
|
|
515
546
|
variant="contrast"
|
|
516
547
|
placement="above-nav"
|
|
517
548
|
// NO_HAND_IMPLEMENT_OK: operator-directed live Free-template pass.
|
|
518
|
-
message=
|
|
549
|
+
message={
|
|
550
|
+
<>
|
|
551
|
+
<a
|
|
552
|
+
href="https://github.com/working-theory-labs/ai-launch-kit"
|
|
553
|
+
className="inline-flex items-center gap-1 font-bold text-current underline underline-offset-2"
|
|
554
|
+
>
|
|
555
|
+
Get started for free
|
|
556
|
+
<svg
|
|
557
|
+
viewBox="0 0 24 24"
|
|
558
|
+
aria-hidden="true"
|
|
559
|
+
className="size-4 fill-current"
|
|
560
|
+
>
|
|
561
|
+
<path d={BRAND_PATHS.github} />
|
|
562
|
+
</svg>
|
|
563
|
+
</a>
|
|
564
|
+
{"\u00A0 "}
|
|
565
|
+
or
|
|
566
|
+
</>
|
|
567
|
+
}
|
|
519
568
|
messageWeight="regular"
|
|
520
569
|
action={{
|
|
521
|
-
label: "
|
|
522
|
-
href: "https://ailaunchkit.ai/
|
|
570
|
+
label: "upgrade to Pro",
|
|
571
|
+
href: "https://ailaunchkit.ai/pricing",
|
|
523
572
|
kind: "link",
|
|
524
573
|
}}
|
|
525
574
|
routes={{ include: [homePath] }}
|
|
@@ -592,7 +641,6 @@ export default async function LocaleLayout({
|
|
|
592
641
|
bottomBarAlign="content"
|
|
593
642
|
copyrightText={copyrightText}
|
|
594
643
|
legalLinks={legalLinks}
|
|
595
|
-
consentSettingsLabel={tConsent("settingsLabel")}
|
|
596
644
|
languageSwitcher={
|
|
597
645
|
<LocaleSwitcherNav
|
|
598
646
|
current={locale}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subscribeNewsletterAction (#6730 D9) — the Server Action twin of the
|
|
3
|
+
* `ailkSubscribeNewsletter` MCP tool.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { subscribeNewsletterAction } from "../newsletter.actions.js";
|
|
7
|
+
|
|
8
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
9
|
+
leads: { subscribe: jest.fn() },
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { leads } from "@working-theory/api-client";
|
|
13
|
+
const mockSubscribe = leads.subscribe as jest.Mock;
|
|
14
|
+
|
|
15
|
+
function formWith(email: string): FormData {
|
|
16
|
+
const fd = new FormData();
|
|
17
|
+
fd.append("email", email);
|
|
18
|
+
fd.append("consent", "true");
|
|
19
|
+
return fd;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
afterEach(() => jest.clearAllMocks());
|
|
23
|
+
|
|
24
|
+
describe("subscribeNewsletterAction", () => {
|
|
25
|
+
it("calls leads.subscribe with source newsletter and the address", async () => {
|
|
26
|
+
mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
|
|
27
|
+
|
|
28
|
+
const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
|
|
29
|
+
|
|
30
|
+
expect(result).toEqual({ kind: "success" });
|
|
31
|
+
expect(mockSubscribe).toHaveBeenCalledWith(
|
|
32
|
+
expect.objectContaining({ source: "newsletter", email: "ada@example.com" }),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("an invalid address → a field error, no API call", async () => {
|
|
37
|
+
const result = await subscribeNewsletterAction(null, formWith("not-an-email"));
|
|
38
|
+
|
|
39
|
+
expect(result).toMatchObject({ kind: "error", fieldErrors: { email: expect.any(String) } });
|
|
40
|
+
expect(mockSubscribe).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("503 lead_capture_not_configured → unavailable", async () => {
|
|
44
|
+
mockSubscribe.mockResolvedValueOnce({
|
|
45
|
+
ok: false,
|
|
46
|
+
status: 503,
|
|
47
|
+
body: { error: "lead_capture_not_configured" },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
|
|
51
|
+
|
|
52
|
+
expect(result).toEqual({ kind: "unavailable" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("any other failure → the generic error, never success", async () => {
|
|
56
|
+
mockSubscribe.mockResolvedValueOnce({ ok: false, status: 503, body: { error: "persist_failed" } });
|
|
57
|
+
expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toMatchObject({
|
|
58
|
+
kind: "error",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
mockSubscribe.mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
|
62
|
+
jest.spyOn(console, "error").mockImplementationOnce(() => undefined);
|
|
63
|
+
expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toMatchObject({
|
|
64
|
+
kind: "error",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("a filled honeypot → success, and nothing is sent", async () => {
|
|
69
|
+
const fd = formWith("ada@example.com");
|
|
70
|
+
fd.append("ailk_hp", "spam");
|
|
71
|
+
|
|
72
|
+
expect(await subscribeNewsletterAction(null, fd)).toEqual({ kind: "success" });
|
|
73
|
+
expect(mockSubscribe).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* newsletter/page.tsx — a signup only when there is somewhere to keep it
|
|
3
|
+
* (#6730 D9).
|
|
4
|
+
*
|
|
5
|
+
* Boundaries mocked: `@working-theory/ui` (the section tree is a stub that
|
|
6
|
+
* renders one element per section, with a `newsletter` section rendering the
|
|
7
|
+
* form its content declares), `@working-theory/api-client`
|
|
8
|
+
* (`leads.captureStatus`), `next-intl/server`, and the Server Action module.
|
|
9
|
+
*/
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { render, screen } from "@testing-library/react";
|
|
12
|
+
|
|
13
|
+
jest.mock("@working-theory/ui", () => ({
|
|
14
|
+
Heading: ({ children, level }: { children: React.ReactNode; level: number }) =>
|
|
15
|
+
React.createElement(`h${level}`, null, children),
|
|
16
|
+
SectionRenderer: ({
|
|
17
|
+
sections,
|
|
18
|
+
}: {
|
|
19
|
+
sections: Array<{
|
|
20
|
+
id: string;
|
|
21
|
+
type: string;
|
|
22
|
+
data: { action?: string; primaryCta?: { href: string } };
|
|
23
|
+
}>;
|
|
24
|
+
}) =>
|
|
25
|
+
React.createElement(
|
|
26
|
+
"div",
|
|
27
|
+
{ "data-testid": "section-renderer" },
|
|
28
|
+
sections.map((s) =>
|
|
29
|
+
s.type === "newsletter"
|
|
30
|
+
? React.createElement(
|
|
31
|
+
"form",
|
|
32
|
+
{ key: s.id, "data-section-id": s.id, action: s.data.action },
|
|
33
|
+
"signup",
|
|
34
|
+
)
|
|
35
|
+
: React.createElement(
|
|
36
|
+
"div",
|
|
37
|
+
{ key: s.id, "data-section-id": s.id },
|
|
38
|
+
s.data.primaryCta
|
|
39
|
+
? React.createElement("a", { href: s.data.primaryCta.href }, "cta")
|
|
40
|
+
: null,
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
localeHref: (locale: string, path: string) => `/${locale}${path}`,
|
|
45
|
+
LeadCaptureForm: (props: { fields?: Array<{ name: string; type: string }> }) =>
|
|
46
|
+
React.createElement("div", {
|
|
47
|
+
"data-testid": "lead-capture-form",
|
|
48
|
+
"data-fields": (props.fields ?? []).map((f) => `${f.name}:${f.type}`).join(","),
|
|
49
|
+
}),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
53
|
+
leads: { captureStatus: jest.fn() },
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
jest.mock("next-intl/server", () => ({
|
|
57
|
+
getTranslations: async () => (key: string) => key,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
// eslint-disable-next-line no-restricted-syntax -- test-seam mock: the Server Action module is a "use server" boundary; the page only passes it through
|
|
61
|
+
jest.mock("../newsletter.actions", () => ({
|
|
62
|
+
subscribeNewsletterAction: jest.fn(),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
import { leads } from "@working-theory/api-client";
|
|
66
|
+
|
|
67
|
+
import NewsletterPage from "../page";
|
|
68
|
+
|
|
69
|
+
const mockCaptureStatus = leads.captureStatus as jest.Mock;
|
|
70
|
+
const status = (newsletter: boolean) => ({
|
|
71
|
+
ok: true,
|
|
72
|
+
status: 200,
|
|
73
|
+
body: { ok: true, data: { contact: true, newsletter } },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const source = {
|
|
77
|
+
frontmatter: {
|
|
78
|
+
type: "NewsletterSignup" as const,
|
|
79
|
+
slug: "newsletter",
|
|
80
|
+
title: "Newsletter",
|
|
81
|
+
description: "What subscribers get.",
|
|
82
|
+
},
|
|
83
|
+
body: "",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** `_base`'s content: a hero whose CTA points at `#signup`, the signup section, an FAQ. */
|
|
87
|
+
const BASE_SECTIONS = [
|
|
88
|
+
{ id: "hero", type: "hero", data: { headline: "News", primaryCta: { label: "Subscribe", href: "#signup" } } },
|
|
89
|
+
{ id: "signup", type: "newsletter", data: { action: "/v1/leads/subscribe", source: "newsletter" } },
|
|
90
|
+
{ id: "faq", type: "faq", data: { items: [] } },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/** #6738's Free content: no signup section. */
|
|
94
|
+
const FREE_SECTIONS = [
|
|
95
|
+
{ id: "hero", type: "hero", data: { headline: "News" } },
|
|
96
|
+
{ id: "faq", type: "faq", data: { items: [] } },
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
async function renderWith(sections: unknown[]) {
|
|
100
|
+
const element = await NewsletterPage({
|
|
101
|
+
params: Promise.resolve({ locale: "en" }),
|
|
102
|
+
getMdxSource: jest.fn().mockResolvedValue(source),
|
|
103
|
+
getPageSections: jest.fn().mockResolvedValue(sections),
|
|
104
|
+
});
|
|
105
|
+
return render(element);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
beforeEach(() => jest.clearAllMocks());
|
|
109
|
+
|
|
110
|
+
describe("NewsletterPage — #6730 D9", () => {
|
|
111
|
+
it("newsletter:true + no newsletter section → one appended form with a single email field", async () => {
|
|
112
|
+
mockCaptureStatus.mockResolvedValue(status(true));
|
|
113
|
+
|
|
114
|
+
await renderWith(FREE_SECTIONS);
|
|
115
|
+
|
|
116
|
+
expect(screen.getAllByTestId("lead-capture-form")).toHaveLength(1);
|
|
117
|
+
expect(screen.getByTestId("lead-capture-form")).toHaveAttribute(
|
|
118
|
+
"data-fields",
|
|
119
|
+
"email:email",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("newsletter:false + no newsletter section → no form at all", async () => {
|
|
124
|
+
mockCaptureStatus.mockResolvedValue(status(false));
|
|
125
|
+
|
|
126
|
+
const { container } = await renderWith(FREE_SECTIONS);
|
|
127
|
+
|
|
128
|
+
expect(screen.queryByTestId("lead-capture-form")).toBeNull();
|
|
129
|
+
expect(container.querySelector("form")).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("newsletter:true + _base's newsletter section → that section renders, no second form", async () => {
|
|
133
|
+
mockCaptureStatus.mockResolvedValue(status(true));
|
|
134
|
+
|
|
135
|
+
const { container } = await renderWith(BASE_SECTIONS);
|
|
136
|
+
|
|
137
|
+
expect(container.querySelector("form[action='/v1/leads/subscribe']")).not.toBeNull();
|
|
138
|
+
expect(screen.queryByTestId("lead-capture-form")).toBeNull();
|
|
139
|
+
expect(container.querySelector("a[href='#signup']")).not.toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it.each([
|
|
143
|
+
["newsletter:false", () => mockCaptureStatus.mockResolvedValue(status(false))],
|
|
144
|
+
["the status read rejecting", () => mockCaptureStatus.mockRejectedValue(new Error("ECONNREFUSED"))],
|
|
145
|
+
["a 404 (older API)", () => mockCaptureStatus.mockResolvedValue({ ok: false, status: 404, body: {} })],
|
|
146
|
+
])(
|
|
147
|
+
"%s + _base's newsletter section → the section is absent, and so is the hero CTA to it",
|
|
148
|
+
async (_label, arrange) => {
|
|
149
|
+
arrange();
|
|
150
|
+
|
|
151
|
+
const { container } = await renderWith(BASE_SECTIONS);
|
|
152
|
+
|
|
153
|
+
expect(container.querySelector("[data-section-id='signup']")).toBeNull();
|
|
154
|
+
expect(container.querySelector("form")).toBeNull();
|
|
155
|
+
expect(container.innerHTML).not.toContain("/v1/leads/subscribe");
|
|
156
|
+
expect(container.querySelector("a[href='#signup']")).toBeNull();
|
|
157
|
+
expect(screen.queryByTestId("lead-capture-form")).toBeNull();
|
|
158
|
+
// The rest of the page still renders.
|
|
159
|
+
expect(container.querySelector("[data-section-id='hero']")).not.toBeNull();
|
|
160
|
+
expect(container.querySelector("[data-section-id='faq']")).not.toBeNull();
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* subscribeNewsletterAction — Server Action paired with the
|
|
5
|
+
* `ailkSubscribeNewsletter` MCP tool (#6730 D9).
|
|
6
|
+
*
|
|
7
|
+
* `/newsletter` renders the shared `LeadCaptureForm` bound to this action
|
|
8
|
+
* when the API accepts a subscription (a database is set up) and the page's
|
|
9
|
+
* content declares no signup section of its own. Validates the address with
|
|
10
|
+
* the tool's own input schema, calls `POST /v1/leads/subscribe` through
|
|
11
|
+
* `@working-theory/api-client`, and maps the outcome the same way
|
|
12
|
+
* `captureLeadAction` does: the API's 503 `lead_capture_not_configured` →
|
|
13
|
+
* `unavailable`; any other non-2xx or thrown error → the generic `error`;
|
|
14
|
+
* only a 2xx is `success`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { leads } from "@working-theory/api-client";
|
|
18
|
+
import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
|
|
19
|
+
import { subscribeNewsletterInputSchema } from "@working-theory/validation";
|
|
20
|
+
|
|
21
|
+
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
22
|
+
|
|
23
|
+
export async function subscribeNewsletterAction(
|
|
24
|
+
_prevState: LeadCaptureFormState,
|
|
25
|
+
formData: FormData,
|
|
26
|
+
): Promise<LeadCaptureFormState> {
|
|
27
|
+
// Honeypot: a bot that fills the hidden field gets a person's answer, and
|
|
28
|
+
// nothing is sent.
|
|
29
|
+
const honeypot = formData.get(LEAD_CAPTURE_HONEYPOT);
|
|
30
|
+
if (typeof honeypot === "string" && honeypot !== "") return { kind: "success" };
|
|
31
|
+
|
|
32
|
+
const parsed = subscribeNewsletterInputSchema.safeParse({
|
|
33
|
+
email: formData.get("email"),
|
|
34
|
+
});
|
|
35
|
+
if (!parsed.success) {
|
|
36
|
+
return {
|
|
37
|
+
kind: "error",
|
|
38
|
+
fieldErrors: { email: parsed.error.issues[0]?.message ?? "Invalid email" },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const siteId = process.env.AILK_SITE_ID ?? "default";
|
|
43
|
+
try {
|
|
44
|
+
const res = await leads.subscribe({
|
|
45
|
+
siteId,
|
|
46
|
+
source: "newsletter",
|
|
47
|
+
email: parsed.data.email,
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const error =
|
|
51
|
+
typeof res.body === "object" && res.body !== null
|
|
52
|
+
? (res.body as { error?: unknown }).error
|
|
53
|
+
: undefined;
|
|
54
|
+
if (res.status === 503 && error === "lead_capture_not_configured") {
|
|
55
|
+
return { kind: "unavailable" };
|
|
56
|
+
}
|
|
57
|
+
return { kind: "error", message: GENERIC_ERROR };
|
|
58
|
+
}
|
|
59
|
+
return { kind: "success" };
|
|
60
|
+
} catch (err) {
|
|
61
|
+
// The api-client never attaches the request payload to a thrown error,
|
|
62
|
+
// so logging `err` carries no subscriber address.
|
|
63
|
+
console.error("[subscribeNewsletterAction]", err);
|
|
64
|
+
return { kind: "error", message: GENERIC_ERROR };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -1,21 +1,78 @@
|
|
|
1
|
-
// Newsletter page (#6658 D5) — a NewsletterSignup at /newsletter.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
1
|
+
// Newsletter page (#6658 D5) — a NewsletterSignup at /newsletter. A
|
|
2
|
+
// module-less surface a template enables by naming it in its roster; with no
|
|
3
|
+
// content the route 404s. Mirrors for/page.tsx: a sections-composed
|
|
4
|
+
// page renders its sections, a prose-only page its MDX body.
|
|
5
|
+
//
|
|
6
|
+
// A signup only when there is somewhere to keep it (#6730 D9). The API says
|
|
7
|
+
// whether it accepts a subscription (`GET /v1/leads/capture` — a database is
|
|
8
|
+
// set up):
|
|
9
|
+
//
|
|
10
|
+
// - accepting: a content `newsletter` section renders as authored (its own
|
|
11
|
+
// form posts to `/v1/leads/subscribe`); a page with no such section gets
|
|
12
|
+
// the shared `LeadCaptureForm` (one email field) appended, bound to
|
|
13
|
+
// `subscribeNewsletterAction`.
|
|
14
|
+
// - otherwise — including an unreachable API — every `newsletter` section
|
|
15
|
+
// is dropped before render, and a leading hero's CTA that pointed at a
|
|
16
|
+
// dropped section's anchor goes with it, so no signup form of any kind
|
|
17
|
+
// renders and no button points at a section that is gone. The API
|
|
18
|
+
// refuses a subscribe in that state anyway, so a cached page can never
|
|
19
|
+
// record a false success.
|
|
20
|
+
import { leads } from "@working-theory/api-client";
|
|
21
|
+
import type {
|
|
22
|
+
getMdxSource as defaultGetMdxSource,
|
|
23
|
+
getPageSections as defaultGetPageSections,
|
|
24
|
+
} from "@working-theory/page-renderer";
|
|
8
25
|
import {
|
|
9
26
|
getMdxSource as loadMdxSource,
|
|
27
|
+
getPageSections as loadPageSections,
|
|
10
28
|
renderPage,
|
|
11
29
|
renderPageMetadata,
|
|
12
30
|
} from "@working-theory/page-renderer";
|
|
31
|
+
import { LeadCaptureForm, localeHref } from "@working-theory/ui";
|
|
13
32
|
import type { Metadata } from "next";
|
|
33
|
+
import { getTranslations } from "next-intl/server";
|
|
14
34
|
import type { ReactElement } from "react";
|
|
15
35
|
|
|
36
|
+
import { subscribeNewsletterAction } from "./newsletter.actions";
|
|
37
|
+
|
|
38
|
+
export const revalidate = 60;
|
|
39
|
+
|
|
16
40
|
interface Props {
|
|
17
41
|
params: Promise<{ locale: string }>;
|
|
18
42
|
getMdxSource?: typeof defaultGetMdxSource;
|
|
43
|
+
getPageSections?: typeof defaultGetPageSections;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
|
|
47
|
+
|
|
48
|
+
/** Whether the API accepts a subscription; `false` for any failed read. */
|
|
49
|
+
async function acceptsSubscription(): Promise<boolean> {
|
|
50
|
+
try {
|
|
51
|
+
const res = await leads.captureStatus();
|
|
52
|
+
return res.ok && res.body.ok === true && res.body.data.newsletter;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Drop every `newsletter` section, and any leading-hero CTA whose `href` is
|
|
60
|
+
* the `#<id>` anchor of a section that was dropped.
|
|
61
|
+
*/
|
|
62
|
+
function withoutSignup(sections: Section[]): Section[] {
|
|
63
|
+
const dropped = new Set(
|
|
64
|
+
sections.filter((s) => s.type === "newsletter").map((s) => `#${s.id}`),
|
|
65
|
+
);
|
|
66
|
+
if (dropped.size === 0) return sections;
|
|
67
|
+
return sections
|
|
68
|
+
.filter((s) => s.type !== "newsletter")
|
|
69
|
+
.map((s, i) => {
|
|
70
|
+
if (i !== 0 || s.type !== "hero") return s;
|
|
71
|
+
const data = { ...s.data };
|
|
72
|
+
if (data.primaryCta && dropped.has(data.primaryCta.href)) delete data.primaryCta;
|
|
73
|
+
if (data.secondaryCta && dropped.has(data.secondaryCta.href)) delete data.secondaryCta;
|
|
74
|
+
return { ...s, data };
|
|
75
|
+
});
|
|
19
76
|
}
|
|
20
77
|
|
|
21
78
|
export async function generateMetadata({
|
|
@@ -32,19 +89,54 @@ export async function generateMetadata({
|
|
|
32
89
|
export default async function Page({
|
|
33
90
|
params,
|
|
34
91
|
getMdxSource,
|
|
92
|
+
getPageSections,
|
|
35
93
|
}: Props): Promise<ReactElement> {
|
|
36
94
|
const { locale } = await params;
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
(
|
|
42
|
-
|
|
43
|
-
|
|
95
|
+
const load = getMdxSource ?? loadMdxSource;
|
|
96
|
+
const loadSections = getPageSections ?? loadPageSections;
|
|
97
|
+
const [source, accepting] = await Promise.all([
|
|
98
|
+
load("NewsletterSignup", "newsletter", locale),
|
|
99
|
+
acceptsSubscription(),
|
|
100
|
+
]);
|
|
101
|
+
const declared = source ? await loadSections(source.frontmatter) : [];
|
|
102
|
+
const composed = declared.length > 0;
|
|
103
|
+
const hasSignupSection = declared.some((s) => s.type === "newsletter");
|
|
104
|
+
|
|
105
|
+
const rendered = await renderPage({
|
|
44
106
|
type: "NewsletterSignup",
|
|
45
107
|
slug: "newsletter",
|
|
46
108
|
locale,
|
|
47
109
|
...(composed ? {} : { bodyMode: "mdx-body" as const }),
|
|
48
110
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
111
|
+
getPageSections: accepting
|
|
112
|
+
? loadSections
|
|
113
|
+
: async (frontmatter: unknown) => withoutSignup(await loadSections(frontmatter)),
|
|
49
114
|
});
|
|
115
|
+
|
|
116
|
+
if (!accepting || hasSignupSection || source === null) return rendered;
|
|
117
|
+
|
|
118
|
+
const [t, tc] = await Promise.all([
|
|
119
|
+
getTranslations({ locale, namespace: "newsletter" }),
|
|
120
|
+
getTranslations({ locale, namespace: "contact" }),
|
|
121
|
+
]);
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
{rendered}
|
|
125
|
+
<LeadCaptureForm
|
|
126
|
+
action={subscribeNewsletterAction}
|
|
127
|
+
fields={[{ name: "email", label: t("emailLabel"), type: "email", required: true }]}
|
|
128
|
+
termsHref={localeHref(locale, "/terms")}
|
|
129
|
+
text={{
|
|
130
|
+
submit: t("submit"),
|
|
131
|
+
sending: t("sending"),
|
|
132
|
+
success: t("success"),
|
|
133
|
+
consentStatement: t("consentStatement"),
|
|
134
|
+
genericError: tc("genericError"),
|
|
135
|
+
unavailableHeading: tc("unavailableHeading"),
|
|
136
|
+
unavailableBody: tc("unavailableBody", { email: "{email}" }),
|
|
137
|
+
unavailableNoEmail: tc("unavailableNoEmail"),
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
</>
|
|
141
|
+
);
|
|
50
142
|
}
|