From d99e50acca96d3500581a074b8b1456552e53b40 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Wed, 26 Aug 2026 22:50:50 -0700 Subject: [PATCH] fix(web): replace marketing pricing and legal 404s with honest pages /en/pricing, /en/legal/terms, and /en/privacy 404'd while the app legal pages were live. Ship a dormant pricing page (no fake Buy) and the same Shannon Labs terms/privacy body, with aliases that 307 to those paths. Co-authored-by: Cursor --- web/app/[locale]/legal/page.tsx | 6 ++ web/app/[locale]/legal/privacy/page.tsx | 45 +++++++++++++ web/app/[locale]/legal/terms/page.tsx | 48 ++++++++++++++ web/app/[locale]/pricing/page.tsx | 63 ++++++++++++++++++ web/app/[locale]/privacy/page.tsx | 6 ++ web/app/[locale]/terms/page.tsx | 6 ++ web/app/globals.css | 72 +++++++++++++++++++++ web/app/sitemap.ts | 2 +- web/components/footer.tsx | 3 + web/lib/docs-ia.test.ts | 15 ++++- web/lib/i18n/links.ts | 9 +++ web/lib/legal-copy.ts | 65 +++++++++++++++++++ web/lib/llms-txt.test.ts | 3 + web/lib/llms-txt.ts | 15 +++++ web/lib/page-meta.test.ts | 3 + web/lib/public-billing-legal-routes.test.ts | 31 +++++++++ 16 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 web/app/[locale]/legal/page.tsx create mode 100644 web/app/[locale]/legal/privacy/page.tsx create mode 100644 web/app/[locale]/legal/terms/page.tsx create mode 100644 web/app/[locale]/pricing/page.tsx create mode 100644 web/app/[locale]/privacy/page.tsx create mode 100644 web/app/[locale]/terms/page.tsx create mode 100644 web/lib/legal-copy.ts create mode 100644 web/lib/public-billing-legal-routes.test.ts diff --git a/web/app/[locale]/legal/page.tsx b/web/app/[locale]/legal/page.tsx new file mode 100644 index 0000000000..23ef17c719 --- /dev/null +++ b/web/app/[locale]/legal/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export default async function LegalIndexPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + redirect(`/${locale}/legal/terms`); +} diff --git a/web/app/[locale]/legal/privacy/page.tsx b/web/app/[locale]/legal/privacy/page.tsx new file mode 100644 index 0000000000..135a25eff1 --- /dev/null +++ b/web/app/[locale]/legal/privacy/page.tsx @@ -0,0 +1,45 @@ +import Link from "next/link"; +import { buildPageMetadata } from "@/lib/page-meta"; +import { LEGAL_UPDATED, PRIVACY_SECTIONS } from "@/lib/legal-copy"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const isZh = locale === "zh"; + return buildPageMetadata({ + path: "/legal/privacy", + locale, + title: isZh ? "隐私政策 · Codewhale" : "Privacy policy · Codewhale", + description: isZh + ? "Shannon Labs 如何在你使用 Codewhale 时处理信息。" + : "How Shannon Labs handles information when you use Codewhale.", + }); +} + +export default async function PrivacyPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const isZh = locale === "zh"; + return ( +
+
+

{isZh ? "法律" : "Legal"}

+

{isZh ? "隐私政策" : "Privacy policy"}

+

+ {isZh ? "生效并最近更新于" : "Effective and last updated"} {LEGAL_UPDATED} + {isZh ? "。以下为具有约束力的英文文本。" : "."} +

+

This policy explains how Shannon Labs handles information when you use Codewhale.

+ {PRIVACY_SECTIONS.map((section) => ( +
+

{section.title}

+

{section.body}

+
+ ))} +

+ {isZh ? "服务条款" : "Terms of service"} + {isZh ? "价格" : "Pricing"} + {isZh ? "返回首页" : "Back home"} +

+
+
+ ); +} diff --git a/web/app/[locale]/legal/terms/page.tsx b/web/app/[locale]/legal/terms/page.tsx new file mode 100644 index 0000000000..b5cf93e38c --- /dev/null +++ b/web/app/[locale]/legal/terms/page.tsx @@ -0,0 +1,48 @@ +import Link from "next/link"; +import { buildPageMetadata } from "@/lib/page-meta"; +import { LEGAL_UPDATED, TERMS_SECTIONS } from "@/lib/legal-copy"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const isZh = locale === "zh"; + return buildPageMetadata({ + path: "/legal/terms", + locale, + title: isZh ? "服务条款 · Codewhale" : "Terms of service · Codewhale", + description: isZh + ? "Shannon Labs 产品 Codewhale 的服务条款。" + : "Terms that govern your use of Codewhale, a Shannon Labs product.", + }); +} + +export default async function TermsPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const isZh = locale === "zh"; + return ( +
+
+

{isZh ? "法律" : "Legal"}

+

{isZh ? "服务条款" : "Terms of service"}

+

+ {isZh ? "生效并最近更新于" : "Effective and last updated"} {LEGAL_UPDATED} + {isZh ? "。以下为具有约束力的英文文本。" : "."} +

+

+ These terms govern your use of Codewhale, a Shannon Labs product. By creating an + account or using the service, you agree to them. +

+ {TERMS_SECTIONS.map((section) => ( +
+

{section.title}

+

{section.body}

+
+ ))} +

+ {isZh ? "隐私政策" : "Privacy policy"} + {isZh ? "价格" : "Pricing"} + {isZh ? "返回首页" : "Back home"} +

+
+
+ ); +} diff --git a/web/app/[locale]/pricing/page.tsx b/web/app/[locale]/pricing/page.tsx new file mode 100644 index 0000000000..6c55761875 --- /dev/null +++ b/web/app/[locale]/pricing/page.tsx @@ -0,0 +1,63 @@ +import Link from "next/link"; +import { APP_LOGIN_URL } from "@/lib/i18n/links"; +import { buildPageMetadata } from "@/lib/page-meta"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const isZh = locale === "zh"; + return buildPageMetadata({ + path: "/pricing", + locale, + title: isZh ? "价格 · Codewhale" : "Pricing · Codewhale", + description: isZh + ? "Codewhale 开源运行时免费。托管会员计费已实现但尚未对公众开放。" + : "The Codewhale open-source runtime is free. Hosted membership billing is built but not for sale yet.", + }); +} + +export default async function PricingPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const isZh = locale === "zh"; + return ( +
+
+
+

{isZh ? "价格" : "Pricing"}

+

{isZh ? "现在没有可以付款的套餐。" : "There is nothing to pay for yet."}

+

+ {isZh + ? "开源运行时免费,自带模型密钥(BYOK)。托管计算的 $10/月会员已经写进产品,但生产环境的计费开关仍是休眠状态——没有结账按钮,也就不会误收一笔钱。" + : "The open-source runtime is free. Bring your own model key. The $10/month Member plan for hosted compute is implemented in the product, but production billing is dormant — so there is no checkout button that could charge anyone by accident."} +

+
    +
  • + {isZh ? "开源 / 自托管" : "Open source / self-hosted"} + {isZh ? "免费。在你的机器上运行,使用你自己的模型密钥。" : "Free. Run on your machine with your own model keys."} +
  • +
  • + {isZh ? "托管会员" : "Hosted Member"} + + {isZh + ? "$10/月、每期 $10 可结转的托管计算额度——已实现,尚未对公众销售。" + : "$10/month with $10 of rollover hosted-compute credits each paid period — built, not for sale to the public."} + +
  • +
+

+ {isZh + ? "模型用量由你连接的提供商计费,Codewhale 不加价。我们不会在计费休眠期间展示一个会失败的“立即购买”。" + : "Model tokens are billed by the provider you connect. Codewhale does not mark them up. We will not show a Buy button that 503s while billing is dormant."} +

+
+ + {isZh ? "安装 →" : "Install →"} + + + {isZh ? "打开应用" : "Open the app"} + +
+
+
+
+ ); +} diff --git a/web/app/[locale]/privacy/page.tsx b/web/app/[locale]/privacy/page.tsx new file mode 100644 index 0000000000..848a07e306 --- /dev/null +++ b/web/app/[locale]/privacy/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export default async function PrivacyAliasPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + redirect(`/${locale}/legal/privacy`); +} diff --git a/web/app/[locale]/terms/page.tsx b/web/app/[locale]/terms/page.tsx new file mode 100644 index 0000000000..d0e3400cb5 --- /dev/null +++ b/web/app/[locale]/terms/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export default async function TermsAliasPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + redirect(`/${locale}/legal/terms`); +} diff --git a/web/app/globals.css b/web/app/globals.css index d85fbbdbbb..0f0d268ad0 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1231,6 +1231,78 @@ a.body-link:hover { background-size: 100% 6px; color: var(--ink); } background: var(--paper-deep); } +.legal-doc { + width: var(--container); + margin-inline: auto; + padding-block: clamp(3.25rem, 6vw, 4.75rem); + max-width: 42rem; +} + +.legal-doc-kicker { + margin: 0 0 0.75rem; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-muted, color-mix(in srgb, var(--ink) 62%, var(--paper))); +} + +.legal-doc h1 { + margin: 0 0 0.5rem; + font-family: var(--font-display), serif; + font-size: clamp(2rem, 4vw, 3rem); + line-height: 1.15; +} + +.legal-doc-updated, +.legal-doc p, +.legal-doc section p { + margin: 0 0 1.15rem; + line-height: 1.6; +} + +.legal-doc section h2 { + margin: 1.75rem 0 0.5rem; + font-size: 1.15rem; +} + +.legal-doc-nav { + display: flex; + flex-wrap: wrap; + gap: 1rem 1.5rem; + margin-top: 2rem; +} + +.legal-doc-nav a { + text-decoration: underline; + text-underline-offset: 0.2rem; +} + +.pricing-page { + max-width: 42rem; +} + +.pricing-list { + list-style: none; + padding: 0; + margin: 1.5rem 0; +} + +.pricing-list li { + display: grid; + gap: 0.35rem; + padding: 1rem 0; + border-top: 1px solid var(--hairline); +} + +.pricing-list li:last-child { + border-bottom: 1px solid var(--hairline); +} + +.pricing-note { + margin: 0 0 1.5rem; + line-height: 1.6; +} + /* ---------- read-only settings reference ---------- */ .settings-preview { background: color-mix(in srgb, var(--paper-deep) 52%, var(--paper)); diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index 29008f6ad9..630563ed1e 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -4,7 +4,7 @@ import { SITE_URL } from "@/lib/page-meta"; // Public, indexable routes (locale-prefixed). /admin and /api are // intentionally excluded; see app/robots.ts. -const PATHS = ["", "/install", "/constitution", "/models", "/runtime", "/docs", "/docs/configuration", "/docs/constitution", "/docs/fleet", "/docs/guide", "/docs/hooks", "/docs/mcp", "/docs/modes", "/docs/runtime-api", "/docs/sandbox", "/docs/subagents", "/docs/tools", "/docs/troubleshooting", "/docs/vocabulary", "/docs/web", "/docs/work", "/faq", "/roadmap", "/feed", "/digest", "/contribute", "/community"]; +const PATHS = ["", "/install", "/constitution", "/models", "/runtime", "/docs", "/docs/configuration", "/docs/constitution", "/docs/fleet", "/docs/guide", "/docs/hooks", "/docs/mcp", "/docs/modes", "/docs/runtime-api", "/docs/sandbox", "/docs/subagents", "/docs/tools", "/docs/troubleshooting", "/docs/vocabulary", "/docs/web", "/docs/work", "/faq", "/roadmap", "/feed", "/digest", "/contribute", "/community", "/pricing", "/legal/terms", "/legal/privacy"]; export default function sitemap(): MetadataRoute.Sitemap { return PATHS.flatMap((path) => diff --git a/web/components/footer.tsx b/web/components/footer.tsx index 790c823ece..b2b8bc1155 100644 --- a/web/components/footer.tsx +++ b/web/components/footer.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { GITEE_ENABLED, type Locale } from "@/lib/i18n/config"; import { getChrome } from "@/lib/i18n/dictionaries"; import { + footerLegalLinks, footerProductLinks, footerProjectLinks, REPO_RELEASES_URL, @@ -20,6 +21,7 @@ export function Footer({ locale = "en" }: { locale?: Locale }) { const homeHref = `/${locale}`; const product = footerProductLinks(locale, chrome); const project = footerProjectLinks(locale, chrome); + const legal = footerLegalLinks(locale); return (