Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions web/app/[locale]/legal/page.tsx
Original file line number Diff line number Diff line change
@@ -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`);
}
45 changes: 45 additions & 0 deletions web/app/[locale]/legal/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="portal-home">
<article className="legal-doc">
<p className="legal-doc-kicker">{isZh ? "法律" : "Legal"}</p>
<h1>{isZh ? "隐私政策" : "Privacy policy"}</h1>
<p className="legal-doc-updated">
{isZh ? "生效并最近更新于" : "Effective and last updated"} {LEGAL_UPDATED}
{isZh ? "。以下为具有约束力的英文文本。" : "."}
</p>
<p>This policy explains how Shannon Labs handles information when you use Codewhale.</p>
{PRIVACY_SECTIONS.map((section) => (
<section key={section.title}>
<h2>{section.title}</h2>
<p>{section.body}</p>
</section>
))}
<p className="legal-doc-nav">
<Link href={`/${locale}/legal/terms`}>{isZh ? "服务条款" : "Terms of service"}</Link>
<Link href={`/${locale}/pricing`}>{isZh ? "价格" : "Pricing"}</Link>
<Link href={`/${locale}`}>{isZh ? "返回首页" : "Back home"}</Link>
</p>
</article>
</div>
);
}
48 changes: 48 additions & 0 deletions web/app/[locale]/legal/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="portal-home">
<article className="legal-doc">
<p className="legal-doc-kicker">{isZh ? "法律" : "Legal"}</p>
<h1>{isZh ? "服务条款" : "Terms of service"}</h1>
<p className="legal-doc-updated">
{isZh ? "生效并最近更新于" : "Effective and last updated"} {LEGAL_UPDATED}
{isZh ? "。以下为具有约束力的英文文本。" : "."}
</p>
<p>
These terms govern your use of Codewhale, a Shannon Labs product. By creating an
account or using the service, you agree to them.
</p>
{TERMS_SECTIONS.map((section) => (
<section key={section.title}>
<h2>{section.title}</h2>
<p>{section.body}</p>
</section>
))}
<p className="legal-doc-nav">
<Link href={`/${locale}/legal/privacy`}>{isZh ? "隐私政策" : "Privacy policy"}</Link>
<Link href={`/${locale}/pricing`}>{isZh ? "价格" : "Pricing"}</Link>
<Link href={`/${locale}`}>{isZh ? "返回首页" : "Back home"}</Link>
</p>
</article>
</div>
);
}
63 changes: 63 additions & 0 deletions web/app/[locale]/pricing/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="portal-home">
<section className="portal-section">
<div className="portal-container pricing-page">
<p className="legal-doc-kicker">{isZh ? "价格" : "Pricing"}</p>
<h1>{isZh ? "现在没有可以付款的套餐。" : "There is nothing to pay for yet."}</h1>
<p className="portal-lede">
{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."}
</p>
<ul className="pricing-list">
<li>
<strong>{isZh ? "开源 / 自托管" : "Open source / self-hosted"}</strong>
<span>{isZh ? "免费。在你的机器上运行,使用你自己的模型密钥。" : "Free. Run on your machine with your own model keys."}</span>
</li>
<li>
<strong>{isZh ? "托管会员" : "Hosted Member"}</strong>
<span>
{isZh
? "$10/月、每期 $10 可结转的托管计算额度——已实现,尚未对公众销售。"
: "$10/month with $10 of rollover hosted-compute credits each paid period — built, not for sale to the public."}
</span>
</li>
</ul>
<p className="pricing-note">
{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."}
</p>
<div className="portal-actions">
<Link className="portal-button portal-button-primary" href={`/${locale}/install`}>
{isZh ? "安装 →" : "Install →"}
</Link>
<a className="portal-button portal-button-secondary" href={APP_LOGIN_URL}>
{isZh ? "打开应用" : "Open the app"}
</a>
</div>
</div>
</section>
</div>
);
}
6 changes: 6 additions & 0 deletions web/app/[locale]/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -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`);
}
6 changes: 6 additions & 0 deletions web/app/[locale]/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -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`);
}
72 changes: 72 additions & 0 deletions web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion web/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
3 changes: 3 additions & 0 deletions web/components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
<footer className="site-footer">
Expand Down Expand Up @@ -52,6 +54,7 @@ export function Footer({ locale = "en" }: { locale?: Locale }) {
<a href={REPO_RELEASES_URL}>{chrome.footerReleasesLink}</a>
</p>
<div>
{legal.map((item) => <Link key={item.href} href={item.href}>{item.label}</Link>)}
{GITEE_ENABLED && <a href="https://gitee.com/Hmbown/CodeWhale">Gitee</a>}
<a href="https://cnb.cool/codewhale.net/codewhale">CNB</a>
<a href="https://npmmirror.com/package/codewhale">npmmirror</a>
Expand Down
15 changes: 14 additions & 1 deletion web/lib/docs-ia.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { contentLocalesForPath } from "./i18n/content-locales";
import { getChrome, getHome } from "./i18n/dictionaries";
import {
currentNavHref,
footerLegalLinks,
footerProductLinks,
footerProjectLinks,
navLinks as buildNavLinks,
Expand Down Expand Up @@ -88,7 +89,13 @@ describe("sitemap and hreflang preservation", () => {
});

it("keeps sitemap and hreflang output aligned with real translation coverage", () => {
expect(sitemapEntries).toHaveLength(78);
expect(sitemapEntries).toHaveLength(84);
for (const path of ["/pricing", "/legal/terms", "/legal/privacy"]) {
expect(
sitemapEntries.some((entry) => entry.url === `${SITE_URL}/en${path}`),
path,
).toBe(true);
}
for (const [path, expectedLocales] of [
["/", locales],
["/docs/guide", contentLocalesForPath("/docs/guide")],
Expand Down Expand Up @@ -199,6 +206,11 @@ describe("navigation parity and accessibility", () => {
`/${locale}/contribute`,
"https://github.com/Hmbown/CodeWhale/blob/main/LICENSE",
]);
expect(footerLegalLinks(locale).map((l) => l.href), `${locale} footer legal`).toEqual([
`/${locale}/pricing`,
`/${locale}/legal/terms`,
`/${locale}/legal/privacy`,
]);
}
});

Expand Down Expand Up @@ -283,6 +295,7 @@ describe("navigation parity and accessibility", () => {
});
expect(footer).toContain("footerProductLinks(locale, chrome)");
expect(footer).toContain("footerProjectLinks(locale, chrome)");
expect(footer).toContain("footerLegalLinks(locale)");
});
});

Expand Down
9 changes: 9 additions & 0 deletions web/lib/i18n/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ export function footerProjectLinks(locale: string, chrome: ChromeDict): ChromeLi
];
}

/** Footer legal / pricing destinations — reachable, never a 404. */
export function footerLegalLinks(locale: string): ChromeLink[] {
return [
{ href: `/${locale}/pricing`, label: "Pricing" },
{ href: `/${locale}/legal/terms`, label: "Terms" },
{ href: `/${locale}/legal/privacy`, label: "Privacy" },
];
}

/**
* The one link in a chrome set that names the page currently being viewed.
*
Expand Down
Loading
Loading