summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src/routes
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 08:54:51 -0600
committerGitHub <[email protected]>2026-02-06 08:54:51 -0600
commit812597bb8b101896a8988493d37261ff851ae502 (patch)
tree3b713c13f49c65665e6a7296ed483a3f6f8befbf /packages/console/app/src/routes
parent0ec5f6608bdfea5be62dbbdc4c04a61de6d3e67c (diff)
downloadopencode-812597bb8b101896a8988493d37261ff851ae502.tar.gz
opencode-812597bb8b101896a8988493d37261ff851ae502.zip
feat(web): i18n (#12471)
Diffstat (limited to 'packages/console/app/src/routes')
-rw-r--r--packages/console/app/src/routes/[...404].tsx14
-rw-r--r--packages/console/app/src/routes/bench/[id].tsx74
-rw-r--r--packages/console/app/src/routes/bench/index.tsx12
-rw-r--r--packages/console/app/src/routes/black.tsx41
-rw-r--r--packages/console/app/src/routes/black/common.tsx13
-rw-r--r--packages/console/app/src/routes/black/index.tsx34
-rw-r--r--packages/console/app/src/routes/black/subscribe/[plan].tsx80
-rw-r--r--packages/console/app/src/routes/black/workspace.tsx41
-rw-r--r--packages/console/app/src/routes/brand/index.css1
-rw-r--r--packages/console/app/src/routes/brand/index.tsx12
-rw-r--r--packages/console/app/src/routes/changelog/index.css1
-rw-r--r--packages/console/app/src/routes/changelog/index.tsx20
-rw-r--r--packages/console/app/src/routes/docs/[...path].ts8
-rw-r--r--packages/console/app/src/routes/docs/index.ts8
-rw-r--r--packages/console/app/src/routes/download/index.css1
-rw-r--r--packages/console/app/src/routes/download/index.tsx112
-rw-r--r--packages/console/app/src/routes/enterprise/index.css1
-rw-r--r--packages/console/app/src/routes/enterprise/index.tsx61
-rw-r--r--packages/console/app/src/routes/index.css1
-rw-r--r--packages/console/app/src/routes/index.tsx143
-rw-r--r--packages/console/app/src/routes/s/[id].ts8
-rw-r--r--packages/console/app/src/routes/t/[...path].tsx8
-rw-r--r--packages/console/app/src/routes/temp.tsx45
-rw-r--r--packages/console/app/src/routes/user-menu.tsx4
-rw-r--r--packages/console/app/src/routes/workspace-picker.tsx14
-rw-r--r--packages/console/app/src/routes/workspace/[id].css29
-rw-r--r--packages/console/app/src/routes/workspace/[id].tsx28
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx35
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx62
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx40
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx25
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx46
-rw-r--r--packages/console/app/src/routes/workspace/[id]/graph-section.tsx44
-rw-r--r--packages/console/app/src/routes/workspace/[id]/index.tsx12
-rw-r--r--packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx39
-rw-r--r--packages/console/app/src/routes/workspace/[id]/members/member-section.tsx109
-rw-r--r--packages/console/app/src/routes/workspace/[id]/members/role-dropdown.tsx6
-rw-r--r--packages/console/app/src/routes/workspace/[id]/model-section.tsx16
-rw-r--r--packages/console/app/src/routes/workspace/[id]/new-user-section.tsx30
-rw-r--r--packages/console/app/src/routes/workspace/[id]/provider-section.tsx40
-rw-r--r--packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx27
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage-section.tsx34
-rw-r--r--packages/console/app/src/routes/workspace/common.tsx2
-rw-r--r--packages/console/app/src/routes/zen/index.css1
-rw-r--r--packages/console/app/src/routes/zen/index.tsx120
45 files changed, 855 insertions, 647 deletions
diff --git a/packages/console/app/src/routes/[...404].tsx b/packages/console/app/src/routes/[...404].tsx
index 1fd378774..7e7111ef6 100644
--- a/packages/console/app/src/routes/[...404].tsx
+++ b/packages/console/app/src/routes/[...404].tsx
@@ -3,11 +3,13 @@ import { Title } from "@solidjs/meta"
import { HttpStatusCode } from "@solidjs/start"
import logoLight from "../asset/logo-ornate-light.svg"
import logoDark from "../asset/logo-ornate-dark.svg"
+import { useI18n } from "~/context/i18n"
export default function NotFound() {
+ const i18n = useI18n()
return (
<main data-page="not-found">
- <Title>Not Found | opencode</Title>
+ <Title>{i18n.t("notFound.title")}</Title>
<HttpStatusCode code={404} />
<div data-component="content">
<section data-component="top">
@@ -15,21 +17,21 @@ export default function NotFound() {
<img data-slot="logo light" src={logoLight} alt="opencode logo light" />
<img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
</a>
- <h1 data-slot="title">404 - Page Not Found</h1>
+ <h1 data-slot="title">{i18n.t("notFound.heading")}</h1>
</section>
<section data-component="actions">
<div data-slot="action">
- <a href="/">Home</a>
+ <a href="/">{i18n.t("notFound.home")}</a>
</div>
<div data-slot="action">
- <a href="/docs">Docs</a>
+ <a href="/docs">{i18n.t("notFound.docs")}</a>
</div>
<div data-slot="action">
- <a href="https://github.com/anomalyco/opencode">GitHub</a>
+ <a href="https://github.com/anomalyco/opencode">{i18n.t("notFound.github")}</a>
</div>
<div data-slot="action">
- <a href="/discord">Discord</a>
+ <a href="/discord">{i18n.t("notFound.discord")}</a>
</div>
</section>
</div>
diff --git a/packages/console/app/src/routes/bench/[id].tsx b/packages/console/app/src/routes/bench/[id].tsx
index 4586eef9b..dd96bcbbc 100644
--- a/packages/console/app/src/routes/bench/[id].tsx
+++ b/packages/console/app/src/routes/bench/[id].tsx
@@ -3,6 +3,7 @@ import { createAsync, query, useParams } from "@solidjs/router"
import { createSignal, For, Show } from "solid-js"
import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
+import { useI18n } from "~/context/i18n"
interface TaskSource {
repo: string
@@ -100,32 +101,33 @@ function formatDuration(ms: number): string {
export default function BenchDetail() {
const params = useParams()
+ const i18n = useI18n()
const [benchmarkId, taskId] = (params.id ?? "").split(":")
const task = createAsync(() => queryTaskDetail(benchmarkId, taskId))
return (
<main data-page="bench-detail">
- <Title>Benchmark - {taskId}</Title>
+ <Title>{i18n.t("bench.detail.title", { task: taskId })}</Title>
<div style={{ padding: "1rem" }}>
- <Show when={task()} fallback={<p>Task not found</p>}>
+ <Show when={task()} fallback={<p>{i18n.t("bench.detail.notFound")}</p>}>
<div style={{ "margin-bottom": "1rem" }}>
<div>
- <strong>Agent: </strong>
- {task()?.agent ?? "N/A"}
+ <strong>{i18n.t("bench.detail.labels.agent")}: </strong>
+ {task()?.agent ?? i18n.t("bench.detail.na")}
</div>
<div>
- <strong>Model: </strong>
- {task()?.model ?? "N/A"}
+ <strong>{i18n.t("bench.detail.labels.model")}: </strong>
+ {task()?.model ?? i18n.t("bench.detail.na")}
</div>
<div>
- <strong>Task: </strong>
+ <strong>{i18n.t("bench.detail.labels.task")}: </strong>
{task()!.task.id}
</div>
</div>
<div style={{ "margin-bottom": "1rem" }}>
<div>
- <strong>Repo: </strong>
+ <strong>{i18n.t("bench.detail.labels.repo")}: </strong>
<a
href={`https://github.com/${task()!.task.source.repo}`}
target="_blank"
@@ -136,7 +138,7 @@ export default function BenchDetail() {
</a>
</div>
<div>
- <strong>From: </strong>
+ <strong>{i18n.t("bench.detail.labels.from")}: </strong>
<a
href={`https://github.com/${task()!.task.source.repo}/commit/${task()!.task.source.from}`}
target="_blank"
@@ -147,7 +149,7 @@ export default function BenchDetail() {
</a>
</div>
<div>
- <strong>To: </strong>
+ <strong>{i18n.t("bench.detail.labels.to")}: </strong>
<a
href={`https://github.com/${task()!.task.source.repo}/commit/${task()!.task.source.to}`}
target="_blank"
@@ -161,11 +163,13 @@ export default function BenchDetail() {
<Show when={task()?.task.prompts && task()!.task.prompts!.length > 0}>
<div style={{ "margin-bottom": "1rem" }}>
- <strong>Prompt:</strong>
+ <strong>{i18n.t("bench.detail.labels.prompt")}:</strong>
<For each={task()!.task.prompts}>
{(p) => (
<div style={{ "margin-top": "0.5rem" }}>
- <div style={{ "font-size": "0.875rem", color: "#666" }}>Commit: {p.commit.slice(0, 7)}</div>
+ <div style={{ "font-size": "0.875rem", color: "#666" }}>
+ {i18n.t("bench.detail.labels.commit")}: {p.commit.slice(0, 7)}
+ </div>
<p style={{ "margin-top": "0.25rem", "white-space": "pre-wrap" }}>{p.prompt}</p>
</div>
)}
@@ -177,33 +181,35 @@ export default function BenchDetail() {
<div style={{ "margin-bottom": "1rem" }}>
<div>
- <strong>Average Duration: </strong>
- {task()?.averageDuration ? formatDuration(task()!.averageDuration!) : "N/A"}
+ <strong>{i18n.t("bench.detail.labels.averageDuration")}: </strong>
+ {task()?.averageDuration ? formatDuration(task()!.averageDuration!) : i18n.t("bench.detail.na")}
</div>
<div>
- <strong>Average Score: </strong>
- {task()?.averageScore?.toFixed(3) ?? "N/A"}
+ <strong>{i18n.t("bench.detail.labels.averageScore")}: </strong>
+ {task()?.averageScore?.toFixed(3) ?? i18n.t("bench.detail.na")}
</div>
<div>
- <strong>Average Cost: </strong>
- {task()?.averageUsage?.cost ? `$${task()!.averageUsage!.cost.toFixed(4)}` : "N/A"}
+ <strong>{i18n.t("bench.detail.labels.averageCost")}: </strong>
+ {task()?.averageUsage?.cost ? `$${task()!.averageUsage!.cost.toFixed(4)}` : i18n.t("bench.detail.na")}
</div>
</div>
<Show when={task()?.summary}>
<div style={{ "margin-bottom": "1rem" }}>
- <strong>Summary:</strong>
+ <strong>{i18n.t("bench.detail.labels.summary")}:</strong>
<p style={{ "margin-top": "0.5rem", "white-space": "pre-wrap" }}>{task()!.summary}</p>
</div>
</Show>
<Show when={task()?.runs && task()!.runs!.length > 0}>
<div style={{ "margin-bottom": "1rem" }}>
- <strong>Runs:</strong>
+ <strong>{i18n.t("bench.detail.labels.runs")}:</strong>
<table style={{ "margin-top": "0.5rem", "border-collapse": "collapse", width: "100%" }}>
<thead>
<tr>
- <th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Run</th>
+ <th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>
+ {i18n.t("bench.detail.table.run")}
+ </th>
<th
style={{
border: "1px solid #ccc",
@@ -212,10 +218,14 @@ export default function BenchDetail() {
"white-space": "nowrap",
}}
>
- Score (Base - Penalty)
+ {i18n.t("bench.detail.table.score")}
+ </th>
+ <th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>
+ {i18n.t("bench.detail.table.cost")}
+ </th>
+ <th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>
+ {i18n.t("bench.detail.table.duration")}
</th>
- <th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Cost</th>
- <th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Duration</th>
<For each={task()!.runs![0]?.scoreDetails}>
{(detail) => (
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>
@@ -234,10 +244,10 @@ export default function BenchDetail() {
{run.score.final.toFixed(3)} ({run.score.base.toFixed(3)} - {run.score.penalty.toFixed(3)})
</td>
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
- {run.usage?.cost ? `$${run.usage.cost.toFixed(4)}` : "N/A"}
+ {run.usage?.cost ? `$${run.usage.cost.toFixed(4)}` : i18n.t("bench.detail.na")}
</td>
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
- {run.duration ? formatDuration(run.duration) : "N/A"}
+ {run.duration ? formatDuration(run.duration) : i18n.t("bench.detail.na")}
</td>
<For each={run.scoreDetails}>
{(detail) => (
@@ -265,17 +275,17 @@ export default function BenchDetail() {
<For each={task()!.runs}>
{(run, index) => (
<div style={{ "margin-top": "1rem" }}>
- <h3 style={{ margin: "0 0 0.5rem 0" }}>Run {index() + 1}</h3>
+ <h3 style={{ margin: "0 0 0.5rem 0" }}>{i18n.t("bench.detail.run.title", { n: index() + 1 })}</h3>
<div>
- <strong>Score: </strong>
- {run.score.final.toFixed(3)} (Base: {run.score.base.toFixed(3)} - Penalty:{" "}
- {run.score.penalty.toFixed(3)})
+ <strong>{i18n.t("bench.detail.labels.score")}: </strong>
+ {run.score.final.toFixed(3)} ({i18n.t("bench.detail.labels.base")}: {run.score.base.toFixed(3)} -{" "}
+ {i18n.t("bench.detail.labels.penalty")}: {run.score.penalty.toFixed(3)})
</div>
<For each={run.scoreDetails}>
{(detail) => (
<div style={{ "margin-top": "1rem", "padding-left": "1rem", "border-left": "2px solid #ccc" }}>
<div>
- {detail.criterion} (weight: {detail.weight}){" "}
+ {detail.criterion} ({i18n.t("bench.detail.labels.weight")}: {detail.weight}){" "}
<For each={detail.judges}>
{(judge) => (
<span
@@ -350,7 +360,7 @@ export default function BenchDetail() {
onClick={() => setJsonExpanded(!jsonExpanded())}
>
<span style={{ "margin-right": "0.5rem" }}>{jsonExpanded() ? "▼" : "▶"}</span>
- Raw JSON
+ {i18n.t("bench.detail.rawJson")}
</button>
<Show when={jsonExpanded()}>
<pre>{JSON.stringify(task(), null, 2)}</pre>
diff --git a/packages/console/app/src/routes/bench/index.tsx b/packages/console/app/src/routes/bench/index.tsx
index 9b8d0b8f2..17798eff4 100644
--- a/packages/console/app/src/routes/bench/index.tsx
+++ b/packages/console/app/src/routes/bench/index.tsx
@@ -3,6 +3,7 @@ import { A, createAsync, query } from "@solidjs/router"
import { createMemo, For, Show } from "solid-js"
import { Database, desc } from "@opencode-ai/console-core/drizzle/index.js"
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
+import { useI18n } from "~/context/i18n"
interface BenchmarkResult {
averageScore: number
@@ -33,6 +34,7 @@ async function getBenchmarks() {
const queryBenchmarks = query(getBenchmarks, "benchmarks.list")
export default function Bench() {
+ const i18n = useI18n()
const benchmarks = createAsync(() => queryBenchmarks())
const taskIds = createMemo(() => {
@@ -47,14 +49,14 @@ export default function Bench() {
return (
<main data-page="bench" style={{ padding: "2rem" }}>
- <Title>Benchmark</Title>
- <h1 style={{ "margin-bottom": "1.5rem" }}>Benchmarks</h1>
+ <Title>{i18n.t("bench.list.title")}</Title>
+ <h1 style={{ "margin-bottom": "1.5rem" }}>{i18n.t("bench.list.heading")}</h1>
<table style={{ "border-collapse": "collapse", width: "100%" }}>
<thead>
<tr>
- <th style={{ "text-align": "left", padding: "0.75rem" }}>Agent</th>
- <th style={{ "text-align": "left", padding: "0.75rem" }}>Model</th>
- <th style={{ "text-align": "left", padding: "0.75rem" }}>Score</th>
+ <th style={{ "text-align": "left", padding: "0.75rem" }}>{i18n.t("bench.list.table.agent")}</th>
+ <th style={{ "text-align": "left", padding: "0.75rem" }}>{i18n.t("bench.list.table.model")}</th>
+ <th style={{ "text-align": "left", padding: "0.75rem" }}>{i18n.t("bench.list.table.score")}</th>
<For each={taskIds()}>{(id) => <th style={{ "text-align": "left", padding: "0.75rem" }}>{id}</th>}</For>
</tr>
</thead>
diff --git a/packages/console/app/src/routes/black.tsx b/packages/console/app/src/routes/black.tsx
index b991a60a7..012b392e2 100644
--- a/packages/console/app/src/routes/black.tsx
+++ b/packages/console/app/src/routes/black.tsx
@@ -3,14 +3,19 @@ import { Title, Meta, Link } from "@solidjs/meta"
import { createMemo, createSignal } from "solid-js"
import { github } from "~/lib/github"
import { config } from "~/config"
+import { useLanguage } from "~/context/language"
+import { LanguagePicker } from "~/component/language-picker"
+import { useI18n } from "~/context/i18n"
import Spotlight, { defaultConfig, type SpotlightAnimationState } from "~/component/spotlight"
import "./black.css"
export default function BlackLayout(props: RouteSectionProps) {
+ const language = useLanguage()
+ const i18n = useI18n()
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
- ? new Intl.NumberFormat("en-US", {
+ ? new Intl.NumberFormat(language.tag(language.locale()), {
notation: "compact",
compactDisplay: "short",
}).format(githubData()!.stars!)
@@ -63,26 +68,17 @@ export default function BlackLayout(props: RouteSectionProps) {
return (
<div data-page="black">
- <Title>OpenCode Black | Access all the world's best coding models</Title>
- <Meta
- name="description"
- content="Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans."
- />
+ <Title>{i18n.t("black.meta.title")}</Title>
+ <Meta name="description" content={i18n.t("black.meta.description")} />
<Link rel="canonical" href={`${config.baseUrl}/black`} />
<Meta property="og:type" content="website" />
<Meta property="og:url" content={`${config.baseUrl}/black`} />
- <Meta property="og:title" content="OpenCode Black | Access all the world's best coding models" />
- <Meta
- property="og:description"
- content="Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans."
- />
+ <Meta property="og:title" content={i18n.t("black.meta.title")} />
+ <Meta property="og:description" content={i18n.t("black.meta.description")} />
<Meta property="og:image" content="/social-share-black.png" />
<Meta name="twitter:card" content="summary_large_image" />
- <Meta name="twitter:title" content="OpenCode Black | Access all the world's best coding models" />
- <Meta
- name="twitter:description"
- content="Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans."
- />
+ <Meta name="twitter:title" content={i18n.t("black.meta.title")} />
+ <Meta name="twitter:description" content={i18n.t("black.meta.description")} />
<Meta name="twitter:image" content="/social-share-black.png" />
<Spotlight config={spotlightConfig} class="header-spotlight" onAnimationFrame={handleAnimationFrame} />
@@ -156,8 +152,8 @@ export default function BlackLayout(props: RouteSectionProps) {
</header>
<main data-component="content">
<div data-slot="hero">
- <h1>Access all the world's best coding models</h1>
- <p>Including Claude, GPT, Gemini and more</p>
+ <h1>{i18n.t("black.hero.title")}</h1>
+ <p>{i18n.t("black.hero.subtitle")}</p>
</div>
<div data-slot="hero-black" style={svgLightingStyle()}>
<svg width="591" height="90" viewBox="0 0 591 90" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -266,14 +262,15 @@ export default function BlackLayout(props: RouteSectionProps) {
©{new Date().getFullYear()} <a href="https://anoma.ly">Anomaly</a>
</span>
<a href={config.github.repoUrl} target="_blank">
- GitHub <span data-slot="github-stars">[{starCount()}]</span>
+ {i18n.t("nav.github")} <span data-slot="github-stars">[{starCount()}]</span>
</a>
- <a href="/docs">Docs</a>
+ <a href="/docs">{i18n.t("nav.docs")}</a>
+ <LanguagePicker align="right" />
<span>
- <A href="/legal/privacy-policy">Privacy</A>
+ <A href="/legal/privacy-policy">{i18n.t("legal.privacy")}</A>
</span>
<span>
- <A href="/legal/terms-of-service">Terms</A>
+ <A href="/legal/terms-of-service">{i18n.t("legal.terms")}</A>
</span>
</div>
<span data-slot="anomaly-alt">
diff --git a/packages/console/app/src/routes/black/common.tsx b/packages/console/app/src/routes/black/common.tsx
index 39844abee..8932a967e 100644
--- a/packages/console/app/src/routes/black/common.tsx
+++ b/packages/console/app/src/routes/black/common.tsx
@@ -1,26 +1,29 @@
import { Match, Switch } from "solid-js"
+import { useI18n } from "~/context/i18n"
export const plans = [
{ id: "20", multiplier: null },
- { id: "100", multiplier: "5x more usage than Black 20" },
- { id: "200", multiplier: "20x more usage than Black 20" },
+ { id: "100", multiplier: "black.plan.multiplier100" },
+ { id: "200", multiplier: "black.plan.multiplier200" },
] as const
export type PlanID = (typeof plans)[number]["id"]
export type Plan = (typeof plans)[number]
export function PlanIcon(props: { plan: string }) {
+ const i18n = useI18n()
+
return (
<Switch>
<Match when={props.plan === "20"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
- <title>Black 20 plan</title>
+ <title>{i18n.t("black.plan.icon20")}</title>
<rect x="0.5" y="0.5" width="23" height="23" stroke="currentColor" />
</svg>
</Match>
<Match when={props.plan === "100"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
- <title>Black 100 plan</title>
+ <title>{i18n.t("black.plan.icon100")}</title>
<rect x="0.5" y="0.5" width="9" height="9" stroke="currentColor" />
<rect x="0.5" y="14.5" width="9" height="9" stroke="currentColor" />
<rect x="14.5" y="0.5" width="9" height="9" stroke="currentColor" />
@@ -29,7 +32,7 @@ export function PlanIcon(props: { plan: string }) {
</Match>
<Match when={props.plan === "200"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
- <title>Black 200 plan</title>
+ <title>{i18n.t("black.plan.icon200")}</title>
<rect x="0.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="0.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="0.5" y="10.5" width="3" height="3" stroke="currentColor" />
diff --git a/packages/console/app/src/routes/black/index.tsx b/packages/console/app/src/routes/black/index.tsx
index 5ea5c3e92..ad7a115d0 100644
--- a/packages/console/app/src/routes/black/index.tsx
+++ b/packages/console/app/src/routes/black/index.tsx
@@ -2,9 +2,11 @@ import { A, useSearchParams } from "@solidjs/router"
import { Title } from "@solidjs/meta"
import { createMemo, createSignal, For, Match, onMount, Show, Switch } from "solid-js"
import { PlanIcon, plans } from "./common"
+import { useI18n } from "~/context/i18n"
export default function Black() {
const [params] = useSearchParams()
+ const i18n = useI18n()
const [selected, setSelected] = createSignal<string | null>((params.plan as string) || null)
const [mounted, setMounted] = createSignal(false)
const selectedPlan = createMemo(() => plans.find((p) => p.id === selected()))
@@ -36,7 +38,7 @@ export default function Black() {
return (
<>
- <Title>opencode</Title>
+ <Title>{i18n.t("black.title")}</Title>
<section data-slot="cta">
<Switch>
<Match when={!selected()}>
@@ -53,9 +55,10 @@ export default function Black() {
<PlanIcon plan={plan.id} />
</div>
<p data-slot="price">
- <span data-slot="amount">${plan.id}</span> <span data-slot="period">per month</span>
+ <span data-slot="amount">${plan.id}</span>{" "}
+ <span data-slot="period">{i18n.t("black.price.perMonth")}</span>
<Show when={plan.multiplier}>
- <span data-slot="multiplier">{plan.multiplier}</span>
+ {(multiplier) => <span data-slot="multiplier">{i18n.t(multiplier())}</span>}
</Show>
</p>
</button>
@@ -72,26 +75,26 @@ export default function Black() {
</div>
<p data-slot="price">
<span data-slot="amount">${plan().id}</span>{" "}
- <span data-slot="period">per person billed monthly</span>
+ <span data-slot="period">{i18n.t("black.price.perPersonBilledMonthly")}</span>
<Show when={plan().multiplier}>
- <span data-slot="multiplier">{plan().multiplier}</span>
+ {(multiplier) => <span data-slot="multiplier">{i18n.t(multiplier())}</span>}
</Show>
</p>
<ul data-slot="terms" style={{ "view-transition-name": `terms-${plan().id}` }}>
- <li>Your subscription will not start immediately</li>
- <li>You will be added to the waitlist and activated soon</li>
- <li>Your card will be only charged when your subscription is activated</li>
- <li>Usage limits apply, heavily automated use may reach limits sooner</li>
- <li>Subscriptions for individuals, contact Enterprise for teams</li>
- <li>Limits may be adjusted and plans may be discontinued in the future</li>
- <li>Cancel your subscription at anytime</li>
+ <li>{i18n.t("black.terms.1")}</li>
+ <li>{i18n.t("black.terms.2")}</li>
+ <li>{i18n.t("black.terms.3")}</li>
+ <li>{i18n.t("black.terms.4")}</li>
+ <li>{i18n.t("black.terms.5")}</li>
+ <li>{i18n.t("black.terms.6")}</li>
+ <li>{i18n.t("black.terms.7")}</li>
</ul>
<div data-slot="actions" style={{ "view-transition-name": `actions-${plan().id}` }}>
<button type="button" onClick={() => cancel()} data-slot="cancel">
- Cancel
+ {i18n.t("common.cancel")}
</button>
<a href={`/black/subscribe/${plan().id}`} data-slot="continue">
- Continue
+ {i18n.t("black.action.continue")}
</a>
</div>
</div>
@@ -100,7 +103,8 @@ export default function Black() {
</Match>
</Switch>
<p data-slot="fine-print" style={{ "view-transition-name": "fine-print" }}>
- Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
+ {i18n.t("black.finePrint.beforeTerms")} ·{" "}
+ <A href="/legal/terms-of-service">{i18n.t("black.finePrint.terms")}</A>
</p>
</section>
</>
diff --git a/packages/console/app/src/routes/black/subscribe/[plan].tsx b/packages/console/app/src/routes/black/subscribe/[plan].tsx
index a7cf92a25..347be7419 100644
--- a/packages/console/app/src/routes/black/subscribe/[plan].tsx
+++ b/packages/console/app/src/routes/black/subscribe/[plan].tsx
@@ -14,6 +14,8 @@ import { createList } from "solid-list"
import { Modal } from "~/component/modal"
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Billing } from "@opencode-ai/console-core/billing.js"
+import { useI18n } from "~/context/i18n"
+import { formError } from "~/lib/form-error"
const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<PlanID, (typeof plans)[number]>
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!)
@@ -56,8 +58,8 @@ const createSetupIntent = async (input: { plan: string; workspaceID: string }) =
"use server"
const { plan, workspaceID } = input
- if (!plan || !["20", "100", "200"].includes(plan)) return { error: "Invalid plan" }
- if (!workspaceID) return { error: "Workspace ID is required" }
+ if (!plan || !["20", "100", "200"].includes(plan)) return { error: formError.invalidPlan }
+ if (!workspaceID) return { error: formError.workspaceRequired }
return withActor(async () => {
const session = await useAuthSession()
@@ -75,7 +77,7 @@ const createSetupIntent = async (input: { plan: string; workspaceID: string }) =
.then((rows) => rows[0]),
)
if (customer?.subscriptionID) {
- return { error: "This workspace already has a subscription" }
+ return { error: formError.alreadySubscribed }
}
let customerID = customer?.customerID
@@ -142,28 +144,34 @@ interface SuccessData {
}
function Failure(props: { message: string }) {
+ const i18n = useI18n()
+
return (
<div data-slot="failure">
- <p data-slot="message">Uh oh! {props.message}</p>
+ <p data-slot="message">
+ {i18n.t("black.subscribe.failurePrefix")} {props.message}
+ </p>
</div>
)
}
function Success(props: SuccessData) {
+ const i18n = useI18n()
+
return (
<div data-slot="success">
- <p data-slot="title">You're on the OpenCode Black waitlist</p>
+ <p data-slot="title">{i18n.t("black.subscribe.success.title")}</p>
<dl data-slot="details">
<div>
- <dt>Subscription plan</dt>
- <dd>OpenCode Black {props.plan}</dd>
+ <dt>{i18n.t("black.subscribe.success.subscriptionPlan")}</dt>
+ <dd>{i18n.t("black.subscribe.success.planName", { plan: props.plan })}</dd>
</div>
<div>
- <dt>Amount</dt>
- <dd>${props.plan} per month</dd>
+ <dt>{i18n.t("black.subscribe.success.amount")}</dt>
+ <dd>{i18n.t("black.subscribe.success.amountValue", { plan: props.plan })}</dd>
</div>
<div>
- <dt>Payment method</dt>
+ <dt>{i18n.t("black.subscribe.success.paymentMethod")}</dt>
<dd>
<Show when={props.paymentMethodLast4} fallback={<span>{props.paymentMethodType}</span>}>
<span>
@@ -173,16 +181,17 @@ function Success(props: SuccessData) {
</dd>
</div>
<div>
- <dt>Date joined</dt>
- <dd>{new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</dd>
+ <dt>{i18n.t("black.subscribe.success.dateJoined")}</dt>
+ <dd>{new Date().toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}</dd>
</div>
</dl>
- <p data-slot="charge-notice">Your card will be charged when your subscription is activated</p>
+ <p data-slot="charge-notice">{i18n.t("black.subscribe.success.chargeNotice")}</p>
</div>
)
}
function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data: SuccessData) => void }) {
+ const i18n = useI18n()
const stripe = useStripe()
const elements = useElements()
const [error, setError] = createSignal<string | undefined>(undefined)
@@ -197,7 +206,7 @@ function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data
const result = await elements()!.submit()
if (result.error) {
- setError(result.error.message ?? "An error occurred")
+ setError(result.error.message ?? i18n.t("black.subscribe.error.generic"))
setLoading(false)
return
}
@@ -214,7 +223,7 @@ function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data
})
if (confirmError) {
- setError(confirmError.message ?? "An error occurred")
+ setError(confirmError.message ?? i18n.t("black.subscribe.error.generic"))
setLoading(false)
return
}
@@ -248,15 +257,16 @@ function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data
<p data-slot="error">{error()}</p>
</Show>
<button type="submit" disabled={loading() || !stripe() || !elements()} data-slot="submit-button">
- {loading() ? "Processing..." : `Subscribe $${props.plan}`}
+ {loading() ? i18n.t("black.subscribe.processing") : i18n.t("black.subscribe.submit", { plan: props.plan })}
</button>
- <p data-slot="charge-notice">You will only be charged when your subscription is activated</p>
+ <p data-slot="charge-notice">{i18n.t("black.subscribe.form.chargeNotice")}</p>
</form>
)
}
export default function BlackSubscribe() {
const params = useParams()
+ const i18n = useI18n()
const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
const plan = planData.id
@@ -267,6 +277,16 @@ export default function BlackSubscribe() {
const [clientSecret, setClientSecret] = createSignal<string | undefined>(undefined)
const [stripe, setStripe] = createSignal<Stripe | undefined>(undefined)
+ const formatError = (error: string) => {
+ if (error === formError.invalidPlan) return i18n.t("black.subscribe.error.invalidPlan")
+ if (error === formError.workspaceRequired) return i18n.t("black.subscribe.error.workspaceRequired")
+ if (error === formError.alreadySubscribed) return i18n.t("black.subscribe.error.alreadySubscribed")
+ if (error === "Invalid plan") return i18n.t("black.subscribe.error.invalidPlan")
+ if (error === "Workspace ID is required") return i18n.t("black.subscribe.error.workspaceRequired")
+ if (error === "This workspace already has a subscription") return i18n.t("black.subscribe.error.alreadySubscribed")
+ return error
+ }
+
// Resolve stripe promise once
createEffect(() => {
stripePromise.then((s) => {
@@ -289,7 +309,7 @@ export default function BlackSubscribe() {
const ws = workspaces()?.find((w) => w.id === id)
if (ws?.billing?.subscriptionID) {
- setFailure("This workspace already has a subscription")
+ setFailure(i18n.t("black.subscribe.error.alreadySubscribed"))
return
}
if (ws?.billing?.paymentMethodID) {
@@ -312,7 +332,7 @@ export default function BlackSubscribe() {
const result = await createSetupIntent({ plan, workspaceID: id })
if (result.error) {
- setFailure(result.error)
+ setFailure(formatError(result.error))
} else if ("clientSecret" in result) {
setClientSecret(result.clientSecret)
}
@@ -338,7 +358,7 @@ export default function BlackSubscribe() {
return (
<>
- <Title>Subscribe to OpenCode Black</Title>
+ <Title>{i18n.t("black.subscribe.title")}</Title>
<section data-slot="subscribe-form">
<div data-slot="form-card">
<Switch>
@@ -347,22 +367,27 @@ export default function BlackSubscribe() {
<Match when={true}>
<>
<div data-slot="plan-header">
- <p data-slot="title">Subscribe to OpenCode Black</p>
+ <p data-slot="title">{i18n.t("black.subscribe.title")}</p>
<p data-slot="price">
- <span data-slot="amount">${planData.id}</span> <span data-slot="period">per month</span>
+ <span data-slot="amount">${planData.id}</span>{" "}
+ <span data-slot="period">{i18n.t("black.price.perMonth")}</span>
<Show when={planData.multiplier}>
- <span data-slot="multiplier">{planData.multiplier}</span>
+ {(multiplier) => <span data-slot="multiplier">{i18n.t(multiplier())}</span>}
</Show>
</p>
</div>
<div data-slot="divider" />
- <p data-slot="section-title">Payment method</p>
+ <p data-slot="section-title">{i18n.t("black.subscribe.paymentMethod")}</p>
<Show
when={clientSecret() && selectedWorkspace() && stripe()}
fallback={
<div data-slot="loading">
- <p>{selectedWorkspace() ? "Loading payment form..." : "Select a workspace to continue"}</p>
+ <p>
+ {selectedWorkspace()
+ ? i18n.t("black.subscribe.loadingPaymentForm")
+ : i18n.t("black.subscribe.selectWorkspaceToContinue")}
+ </p>
</div>
}
>
@@ -410,7 +435,7 @@ export default function BlackSubscribe() {
</div>
{/* Workspace picker modal */}
- <Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title="Select a workspace for this plan">
+ <Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title={i18n.t("black.workspace.selectPlan")}>
<div data-slot="workspace-picker">
<ul
ref={listRef}
@@ -441,7 +466,8 @@ export default function BlackSubscribe() {
</div>
</Modal>
<p data-slot="fine-print">
- Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
+ {i18n.t("black.finePrint.beforeTerms")} ·{" "}
+ <A href="/legal/terms-of-service">{i18n.t("black.finePrint.terms")}</A>
</p>
</section>
</>
diff --git a/packages/console/app/src/routes/black/workspace.tsx b/packages/console/app/src/routes/black/workspace.tsx
index 0eca8cd11..03950a243 100644
--- a/packages/console/app/src/routes/black/workspace.tsx
+++ b/packages/console/app/src/routes/black/workspace.tsx
@@ -5,13 +5,18 @@ import { github } from "~/lib/github"
import { createEffect, createMemo, For, onMount } from "solid-js"
import { config } from "~/config"
import { createList } from "solid-list"
+import { useLanguage } from "~/context/language"
+import { LanguagePicker } from "~/component/language-picker"
+import { useI18n } from "~/context/i18n"
export default function BlackWorkspace() {
const navigate = useNavigate()
+ const language = useLanguage()
+ const i18n = useI18n()
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
- ? new Intl.NumberFormat("en-US", {
+ ? new Intl.NumberFormat(language.tag(language.locale()), {
notation: "compact",
compactDisplay: "short",
}).format(githubData()!.stars!)
@@ -20,15 +25,18 @@ export default function BlackWorkspace() {
// TODO: Frank, replace with real workspaces
const workspaces = [
- { name: "Workspace 1", id: "wrk_123" },
- { name: "Workspace 2", id: "wrk_456" },
- { name: "Workspace 3", id: "wrk_789" },
- { name: "Workspace 4", id: "wrk_111" },
- { name: "Workspace 5", id: "wrk_222" },
- { name: "Workspace 6", id: "wrk_333" },
- { name: "Workspace 7", id: "wrk_444" },
- { name: "Workspace 8", id: "wrk_555" },
- ]
+ { id: "wrk_123", n: 1 },
+ { id: "wrk_456", n: 2 },
+ { id: "wrk_789", n: 3 },
+ { id: "wrk_111", n: 4 },
+ { id: "wrk_222", n: 5 },
+ { id: "wrk_333", n: 6 },
+ { id: "wrk_444", n: 7 },
+ { id: "wrk_555", n: 8 },
+ ].map((workspace) => ({
+ ...workspace,
+ name: i18n.t("black.workspace.name", { n: workspace.n }),
+ }))
let listRef: HTMLUListElement | undefined
@@ -51,7 +59,7 @@ export default function BlackWorkspace() {
return (
<div data-page="black">
- <Title>opencode</Title>
+ <Title>{i18n.t("black.workspace.title")}</Title>
<div data-component="header-gradient" />
<header data-component="header">
<div data-component="header-logo">
@@ -171,7 +179,7 @@ export default function BlackWorkspace() {
</svg>
</div>
<section data-slot="select-workspace">
- <p data-slot="select-workspace-title">Select a workspace for this plan</p>
+ <p data-slot="select-workspace-title">{i18n.t("black.workspace.selectPlan")}</p>
<ul
ref={listRef}
data-slot="workspaces"
@@ -210,14 +218,15 @@ export default function BlackWorkspace() {
©{new Date().getFullYear()} <a href="https://anoma.ly">Anomaly</a>
</span>
<a href={config.github.repoUrl} target="_blank">
- GitHub <span data-slot="github-stars">[{starCount()}]</span>
+ {i18n.t("nav.github")} <span data-slot="github-stars">[{starCount()}]</span>
</a>
- <a href="/docs">Docs</a>
+ <a href="/docs">{i18n.t("nav.docs")}</a>
+ <LanguagePicker align="right" />
<span>
- <A href="/legal/privacy-policy">Privacy</A>
+ <A href="/legal/privacy-policy">{i18n.t("legal.privacy")}</A>
</span>
<span>
- <A href="/legal/terms-of-service">Terms</A>
+ <A href="/legal/terms-of-service">{i18n.t("legal.terms")}</A>
</span>
</div>
<span data-slot="anomaly-alt">
diff --git a/packages/console/app/src/routes/brand/index.css b/packages/console/app/src/routes/brand/index.css
index 2bfe5711a..6fe7c9a8d 100644
--- a/packages/console/app/src/routes/brand/index.css
+++ b/packages/console/app/src/routes/brand/index.css
@@ -118,6 +118,7 @@
display: flex;
align-items: center;
gap: 8px;
+ white-space: nowrap;
@media (max-width: 55rem) {
display: none;
diff --git a/packages/console/app/src/routes/brand/index.tsx b/packages/console/app/src/routes/brand/index.tsx
index 6aac4517a..a1bc6c54c 100644
--- a/packages/console/app/src/routes/brand/index.tsx
+++ b/packages/console/app/src/routes/brand/index.tsx
@@ -4,6 +4,7 @@ import { Header } from "~/component/header"
import { config } from "~/config"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
+import { useI18n } from "~/context/i18n"
import previewLogoLight from "../../asset/brand/preview-opencode-logo-light.png"
import previewLogoDark from "../../asset/brand/preview-opencode-logo-dark.png"
import previewWordmarkLight from "../../asset/brand/preview-opencode-wordmark-light.png"
@@ -25,6 +26,7 @@ import wordmarkSimpleDarkSvg from "../../asset/brand/opencode-wordmark-simple-da
const brandAssets = "/opencode-brand-assets.zip"
export default function Brand() {
+ const i18n = useI18n()
const downloadFile = async (url: string, filename: string) => {
try {
const response = await fetch(url)
@@ -53,21 +55,21 @@ export default function Brand() {
return (
<main data-page="enterprise">
- <Title>OpenCode | Brand</Title>
+ <Title>{i18n.t("brand.title")}</Title>
<Link rel="canonical" href={`${config.baseUrl}/brand`} />
- <Meta name="description" content="OpenCode brand guidelines" />
+ <Meta name="description" content={i18n.t("brand.meta.description")} />
<div data-component="container">
<Header />
<div data-component="content">
<section data-component="brand-content">
- <h1>Brand guidelines</h1>
- <p>Resources and assets to help you work with the OpenCode brand.</p>
+ <h1>{i18n.t("brand.heading")}</h1>
+ <p>{i18n.t("brand.subtitle")}</p>
<button
data-component="download-button"
onClick={() => downloadFile(brandAssets, "opencode-brand-assets.zip")}
>
- Download all assets
+ {i18n.t("brand.downloadAll")}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
diff --git a/packages/console/app/src/routes/changelog/index.css b/packages/console/app/src/routes/changelog/index.css
index 950762ef6..b441754a6 100644
--- a/packages/console/app/src/routes/changelog/index.css
+++ b/packages/console/app/src/routes/changelog/index.css
@@ -113,6 +113,7 @@
display: flex;
align-items: center;
gap: 8px;
+ white-space: nowrap;
@media (max-width: 55rem) {
display: none;
diff --git a/packages/console/app/src/routes/changelog/index.tsx b/packages/console/app/src/routes/changelog/index.tsx
index dff0a427f..595ae9d1b 100644
--- a/packages/console/app/src/routes/changelog/index.tsx
+++ b/packages/console/app/src/routes/changelog/index.tsx
@@ -8,10 +8,12 @@ import { config } from "~/config"
import { changelog } from "~/lib/changelog"
import type { HighlightGroup } from "~/lib/changelog"
import { For, Show, createSignal } from "solid-js"
+import { useI18n } from "~/context/i18n"
+import { useLanguage } from "~/context/language"
-function formatDate(dateString: string) {
+function formatDate(dateString: string, locale: string) {
const date = new Date(dateString)
- return date.toLocaleDateString("en-US", {
+ return date.toLocaleDateString(locale, {
year: "numeric",
month: "short",
day: "numeric",
@@ -97,28 +99,30 @@ function CollapsibleSections(props: { sections: { title: string; items: string[]
}
export default function Changelog() {
+ const i18n = useI18n()
+ const language = useLanguage()
const data = createAsync(() => changelog())
const releases = () => data() ?? []
return (
<main data-page="changelog">
- <Title>OpenCode | Changelog</Title>
+ <Title>{i18n.t("changelog.title")}</Title>
<Link rel="canonical" href={`${config.baseUrl}/changelog`} />
- <Meta name="description" content="OpenCode release notes and changelog" />
+ <Meta name="description" content={i18n.t("changelog.meta.description")} />
<div data-component="container">
<Header />
<div data-component="content">
<section data-component="changelog-hero">
- <h1>Changelog</h1>
- <p>New updates and improvements to OpenCode</p>
+ <h1>{i18n.t("changelog.hero.title")}</h1>
+ <p>{i18n.t("changelog.hero.subtitle")}</p>
</section>
<section data-component="releases">
<Show when={releases().length === 0}>
<p>
- No changelog entries found. <a href="/changelog.json">View JSON</a>
+ {i18n.t("changelog.empty")} <a href="/changelog.json">{i18n.t("changelog.viewJson")}</a>
</p>
</Show>
<For each={releases()}>
@@ -131,7 +135,7 @@ export default function Changelog() {
{release.tag}
</a>
</div>
- <time dateTime={release.date}>{formatDate(release.date)}</time>
+ <time dateTime={release.date}>{formatDate(release.date, language.tag(language.locale()))}</time>
</header>
<div data-slot="content">
<Show when={release.highlights.length > 0}>
diff --git a/packages/console/app/src/routes/docs/[...path].ts b/packages/console/app/src/routes/docs/[...path].ts
index f07781583..5493c31a6 100644
--- a/packages/console/app/src/routes/docs/[...path].ts
+++ b/packages/console/app/src/routes/docs/[...path].ts
@@ -1,12 +1,18 @@
import type { APIEvent } from "@solidjs/start/server"
+import { localeFromCookieHeader, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
const url = new URL(req.url)
const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}`
+
+ const headers = new Headers(req.headers)
+ const locale = localeFromCookieHeader(req.headers.get("cookie"))
+ if (locale) headers.set("accept-language", tag(locale))
+
const response = await fetch(targetUrl, {
method: req.method,
- headers: req.headers,
+ headers,
body: req.body,
})
return response
diff --git a/packages/console/app/src/routes/docs/index.ts b/packages/console/app/src/routes/docs/index.ts
index f07781583..5493c31a6 100644
--- a/packages/console/app/src/routes/docs/index.ts
+++ b/packages/console/app/src/routes/docs/index.ts
@@ -1,12 +1,18 @@
import type { APIEvent } from "@solidjs/start/server"
+import { localeFromCookieHeader, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
const url = new URL(req.url)
const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}`
+
+ const headers = new Headers(req.headers)
+ const locale = localeFromCookieHeader(req.headers.get("cookie"))
+ if (locale) headers.set("accept-language", tag(locale))
+
const response = await fetch(targetUrl, {
method: req.method,
- headers: req.headers,
+ headers,
body: req.body,
})
return response
diff --git a/packages/console/app/src/routes/download/index.css b/packages/console/app/src/routes/download/index.css
index a5ca692a2..41bb55eee 100644
--- a/packages/console/app/src/routes/download/index.css
+++ b/packages/console/app/src/routes/download/index.css
@@ -114,6 +114,7 @@
border-radius: 4px;
font-weight: 500;
text-decoration: none;
+ white-space: nowrap;
@media (max-width: 55rem) {
display: none;
diff --git a/packages/console/app/src/routes/download/index.tsx b/packages/console/app/src/routes/download/index.tsx
index 8b8f50968..0c89e4abd 100644
--- a/packages/console/app/src/routes/download/index.tsx
+++ b/packages/console/app/src/routes/download/index.tsx
@@ -10,6 +10,7 @@ import { Legal } from "~/component/legal"
import { config } from "~/config"
import { createSignal, onMount, Show, JSX } from "solid-js"
import { DownloadPlatform } from "./types"
+import { useI18n } from "~/context/i18n"
type OS = "macOS" | "Windows" | "Linux" | null
@@ -64,6 +65,7 @@ function CopyStatus() {
}
export default function Download() {
+ const i18n = useI18n()
const [detectedOS, setDetectedOS] = createSignal<OS>(null)
onMount(() => {
@@ -80,24 +82,24 @@ export default function Download() {
}
return (
<main data-page="download">
- <Title>OpenCode | Download</Title>
+ <Title>{i18n.t("download.title")}</Title>
<Link rel="canonical" href={`${config.baseUrl}/download`} />
- <Meta name="description" content="Download OpenCode for macOS, Windows, and Linux" />
+ <Meta name="description" content={i18n.t("download.meta.description")} />
<div data-component="container">
<Header hideGetStarted />
<div data-component="content">
<section data-component="download-hero">
<div data-component="hero-icon">
- <img src={desktopAppIcon} alt="OpenCode Desktop" />
+ <img src={desktopAppIcon} alt="" />
</div>
<div data-component="hero-text">
- <h1>Download OpenCode</h1>
- <p>Available in Beta for macOS, Windows, and Linux</p>
+ <h1>{i18n.t("download.hero.title")}</h1>
+ <p>{i18n.t("download.hero.subtitle")}</p>
<Show when={detectedOS()}>
<a href={getDownloadHref(getDownloadPlatform(detectedOS()))} data-component="download-button">
<IconDownload />
- Download for {detectedOS()}
+ {i18n.t("download.hero.button", { os: detectedOS()! })}
</a>
</Show>
</div>
@@ -105,7 +107,7 @@ export default function Download() {
<section data-component="download-section">
<div data-component="section-label">
- <span>[1]</span> OpenCode Terminal
+ <span>[1]</span> {i18n.t("download.section.terminal")}
</div>
<div data-component="section-content">
<button
@@ -146,7 +148,7 @@ export default function Download() {
<section data-component="download-section">
<div data-component="section-label">
- <span>[2]</span> OpenCode Desktop (Beta)
+ <span>[2]</span> {i18n.t("download.section.desktop")}
</div>
<div data-component="section-content">
<button data-component="cli-row" onClick={handleCopyClick("brew install --cask opencode-desktop")}>
@@ -165,12 +167,10 @@ export default function Download() {
/>
</svg>
</span>
- <span>
- macOS (<span data-slot="hide-narrow">Apple </span>Silicon)
- </span>
+ <span>{i18n.t("download.platform.macosAppleSilicon")}</span>
</div>
<a href={getDownloadHref("darwin-aarch64-dmg")} data-component="action-button">
- Download
+ {i18n.t("download.action.download")}
</a>
</div>
<div data-component="download-row">
@@ -183,10 +183,10 @@ export default function Download() {
/>
</svg>
</span>
- <span>macOS (Intel)</span>
+ <span>{i18n.t("download.platform.macosIntel")}</span>
</div>
<a href={getDownloadHref("darwin-x64-dmg")} data-component="action-button">
- Download
+ {i18n.t("download.action.download")}
</a>
</div>
<div data-component="download-row">
@@ -206,10 +206,10 @@ export default function Download() {
</defs>
</svg>
</span>
- <span>Windows (x64)</span>
+ <span>{i18n.t("download.platform.windowsX64")}</span>
</div>
<a href={getDownloadHref("windows-x64-nsis")} data-component="action-button">
- Download
+ {i18n.t("download.action.download")}
</a>
</div>
<div data-component="download-row">
@@ -222,10 +222,10 @@ export default function Download() {
/>
</svg>
</span>
- <span>Linux (.deb)</span>
+ <span>{i18n.t("download.platform.linuxDeb")}</span>
</div>
<a href={getDownloadHref("linux-x64-deb")} data-component="action-button">
- Download
+ {i18n.t("download.action.download")}
</a>
</div>
<div data-component="download-row">
@@ -238,10 +238,10 @@ export default function Download() {
/>
</svg>
</span>
- <span>Linux (.rpm)</span>
+ <span>{i18n.t("download.platform.linuxRpm")}</span>
</div>
<a href={getDownloadHref("linux-x64-rpm")} data-component="action-button">
- Download
+ {i18n.t("download.action.download")}
</a>
</div>
{/* Disabled temporarily as it doesn't work */}
@@ -266,7 +266,7 @@ export default function Download() {
<section data-component="download-section">
<div data-component="section-label">
- <span>[3]</span> OpenCode Extensions
+ <span>[3]</span> {i18n.t("download.section.extensions")}
</div>
<div data-component="section-content">
<div data-component="download-row">
@@ -289,7 +289,7 @@ export default function Download() {
<span>VS Code</span>
</div>
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
- Install
+ {i18n.t("download.action.install")}
</a>
</div>
@@ -313,7 +313,7 @@ export default function Download() {
<span>Cursor</span>
</div>
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
- Install
+ {i18n.t("download.action.install")}
</a>
</div>
@@ -330,7 +330,7 @@ export default function Download() {
<span>Zed</span>
</div>
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
- Install
+ {i18n.t("download.action.install")}
</a>
</div>
@@ -347,7 +347,7 @@ export default function Download() {
<span>Windsurf</span>
</div>
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
- Install
+ {i18n.t("download.action.install")}
</a>
</div>
@@ -364,7 +364,7 @@ export default function Download() {
<span>VSCodium</span>
</div>
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
- Install
+ {i18n.t("download.action.install")}
</a>
</div>
</div>
@@ -372,7 +372,7 @@ export default function Download() {
<section data-component="download-section">
<div data-component="section-label">
- <span>[4]</span> OpenCode Integrations
+ <span>[4]</span> {i18n.t("download.section.integrations")}
</div>
<div data-component="section-content">
<div data-component="download-row">
@@ -388,7 +388,7 @@ export default function Download() {
<span>GitHub</span>
</div>
<a href="https://opencode.ai/docs/github/" data-component="action-button">
- Install
+ {i18n.t("download.action.install")}
</a>
</div>
@@ -405,7 +405,7 @@ export default function Download() {
<span>GitLab</span>
</div>
<a href="https://opencode.ai/docs/gitlab/" data-component="action-button">
- Install
+ {i18n.t("download.action.install")}
</a>
</div>
</div>
@@ -414,61 +414,57 @@ export default function Download() {
<section data-component="faq">
<div data-slot="section-title">
- <h3>FAQ</h3>
+ <h3>{i18n.t("common.faq")}</h3>
</div>
<ul>
<li>
- <Faq question="What is OpenCode?">
- OpenCode is an open source agent that helps you write and run code with any AI model. It's available as
- a terminal-based interface, desktop app, or IDE extension.
- </Faq>
+ <Faq question={i18n.t("home.faq.q1")}>{i18n.t("home.faq.a1")}</Faq>
</li>
<li>
- <Faq question="How do I use OpenCode?">
- The easiest way to get started is to read the <a href="/docs">intro</a>.
+ <Faq question={i18n.t("home.faq.q2")}>
+ {i18n.t("home.faq.a2.before")} <a href="/docs">{i18n.t("home.faq.a2.link")}</a>.
</Faq>
</li>
<li>
- <Faq question="Do I need extra AI subscriptions to use OpenCode?">
- Not necessarily, but probably. You'll need an AI subscription if you want to connect OpenCode to a paid
- provider, although you can work with{" "}
+ <Faq question={i18n.t("home.faq.q3")}>
+ {i18n.t("download.faq.a3.beforeLocal")}{" "}
<a href="/docs/providers/#lm-studio" target="_blank">
- local models
+ {i18n.t("download.faq.a3.localLink")}
</a>{" "}
- for free. While we encourage users to use <A href="/zen">Zen</A>, OpenCode works with all popular
- providers such as OpenAI, Anthropic, xAI etc.
+ {i18n.t("download.faq.a3.afterLocal.beforeZen")} <A href="/zen">{i18n.t("nav.zen")}</A>
+ {i18n.t("download.faq.a3.afterZen")}
</Faq>
</li>
<li>
- <Faq question="Can I only use OpenCode in the terminal?">
- Not anymore! OpenCode is now available as an app for your <a href="/download">desktop</a> and{" "}
- <a href="/docs/cli/#web">web</a>!
+ <Faq question={i18n.t("home.faq.q5")}>
+ {i18n.t("home.faq.a5.beforeDesktop")} <a href="/download">{i18n.t("home.faq.a5.desktop")}</a>{" "}
+ {i18n.t("home.faq.a5.and")} <a href="/docs/cli/#web">{i18n.t("home.faq.a5.web")}</a>!
</Faq>
</li>
<li>
- <Faq question="How much does OpenCode cost?">
- OpenCode is 100% free to use. Any additional costs will come from your subscription to a model provider.
- While OpenCode works with any model provider, we recommend using <A href="/zen">Zen</A>.
+ <Faq question={i18n.t("home.faq.q6")}>
+ {i18n.t("download.faq.a5.p1")} {i18n.t("download.faq.a5.p2.beforeZen")}{" "}
+ <A href="/zen">{i18n.t("nav.zen")}</A>
+ {i18n.t("download.faq.a5.p2.afterZen")}
</Faq>
</li>
<li>
- <Faq question="What about data and privacy?">
- Your data and information is only stored when you create sharable links in OpenCode. Learn more about{" "}
- <a href="/docs/share/#privacy">share pages</a>.
+ <Faq question={i18n.t("home.faq.q7")}>
+ {i18n.t("download.faq.a6.p1")} {i18n.t("download.faq.a6.p2.beforeShare")}{" "}
+ <a href="/docs/share/#privacy">{i18n.t("download.faq.a6.shareLink")}</a>.
</Faq>
</li>
<li>
- <Faq question="Is OpenCode open source?">
- Yes, OpenCode is fully open source. The source code is public on{" "}
+ <Faq question={i18n.t("home.faq.q8")}>
+ {i18n.t("home.faq.a8.p1")}{" "}
<a href={config.github.repoUrl} target="_blank">
- GitHub
+ {i18n.t("nav.github")}
</a>{" "}
- under the{" "}
+ {i18n.t("home.faq.a8.p2")}{" "}
<a href={`${config.github.repoUrl}?tab=MIT-1-ov-file#readme`} target="_blank">
- MIT License
+ {i18n.t("home.faq.a8.mitLicense")}
</a>
- , meaning anyone can use, modify, or contribute to its development. Anyone from the community can file
- issues, submit pull requests, and extend functionality.
+ {i18n.t("home.faq.a8.p3")}
</Faq>
</li>
</ul>
diff --git a/packages/console/app/src/routes/enterprise/index.css b/packages/console/app/src/routes/enterprise/index.css
index 7eebf16ce..d42a817e6 100644
--- a/packages/console/app/src/routes/enterprise/index.css
+++ b/packages/console/app/src/routes/enterprise/index.css
@@ -117,6 +117,7 @@
display: flex;
align-items: center;
gap: 8px;
+ white-space: nowrap;
@media (max-width: 55rem) {
display: none;
diff --git a/packages/console/app/src/routes/enterprise/index.tsx b/packages/console/app/src/routes/enterprise/index.tsx
index 095ed97a2..c9f487490 100644
--- a/packages/console/app/src/routes/enterprise/index.tsx
+++ b/packages/console/app/src/routes/enterprise/index.tsx
@@ -6,8 +6,10 @@ import { Header } from "~/component/header"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import { Faq } from "~/component/faq"
+import { useI18n } from "~/context/i18n"
export default function Enterprise() {
+ const i18n = useI18n()
const [formData, setFormData] = createSignal({
name: "",
role: "",
@@ -54,9 +56,9 @@ export default function Enterprise() {
return (
<main data-page="enterprise">
- <Title>OpenCode | Enterprise solutions for your organisation</Title>
+ <Title>{i18n.t("enterprise.title")}</Title>
<Link rel="canonical" href={`${config.baseUrl}/enterprise`} />
- <Meta name="description" content="Contact OpenCode for enterprise solutions" />
+ <Meta name="description" content={i18n.t("enterprise.meta.description")} />
<div data-component="container">
<Header />
@@ -64,13 +66,9 @@ export default function Enterprise() {
<section data-component="enterprise-content">
<div data-component="enterprise-columns">
<div data-component="enterprise-column-1">
- <h1>Your code is yours</h1>
- <p>
- OpenCode operates securely inside your organization with no data or context stored and no licensing
- restrictions or ownership claims. Start a trial with your team, then deploy it across your
- organization by integrating it with your SSO and internal AI gateway.
- </p>
- <p>Let us know and how we can help.</p>
+ <h1>{i18n.t("enterprise.hero.title")}</h1>
+ <p>{i18n.t("enterprise.hero.body1")}</p>
+ <p>{i18n.t("enterprise.hero.body2")}</p>
<Show when={false}>
<div data-component="testimonial">
@@ -150,59 +148,59 @@ export default function Enterprise() {
<div data-component="enterprise-form">
<form onSubmit={handleSubmit}>
<div data-component="form-group">
- <label for="name">Full name</label>
+ <label for="name">{i18n.t("enterprise.form.name.label")}</label>
<input
id="name"
type="text"
required
value={formData().name}
onInput={handleInputChange("name")}
- placeholder="Jeff Bezos"
+ placeholder={i18n.t("enterprise.form.name.placeholder")}
/>
</div>
<div data-component="form-group">
- <label for="role">Role</label>
+ <label for="role">{i18n.t("enterprise.form.role.label")}</label>
<input
id="role"
type="text"
required
value={formData().role}
onInput={handleInputChange("role")}
- placeholder="Executive Chairman"
+ placeholder={i18n.t("enterprise.form.role.placeholder")}
/>
</div>
<div data-component="form-group">
- <label for="email">Company email</label>
+ <label for="email">{i18n.t("enterprise.form.email.label")}</label>
<input
id="email"
type="email"
required
value={formData().email}
onInput={handleInputChange("email")}
- placeholder="[email protected]"
+ placeholder={i18n.t("enterprise.form.email.placeholder")}
/>
</div>
<div data-component="form-group">
- <label for="message">What problem are you trying to solve?</label>
+ <label for="message">{i18n.t("enterprise.form.message.label")}</label>
<textarea
id="message"
required
rows={5}
value={formData().message}
onInput={handleInputChange("message")}
- placeholder="We need help with..."
+ placeholder={i18n.t("enterprise.form.message.placeholder")}
/>
</div>
<button type="submit" disabled={isSubmitting()} data-component="submit-button">
- {isSubmitting() ? "Sending..." : "Send"}
+ {isSubmitting() ? i18n.t("enterprise.form.sending") : i18n.t("enterprise.form.send")}
</button>
</form>
- {showSuccess() && <div data-component="success-message">Message sent, we'll be in touch soon.</div>}
+ {showSuccess() && <div data-component="success-message">{i18n.t("enterprise.form.success")}</div>}
</div>
</div>
</div>
@@ -210,35 +208,20 @@ export default function Enterprise() {
<section data-component="faq">
<div data-slot="section-title">
- <h3>FAQ</h3>
+ <h3>{i18n.t("enterprise.faq.title")}</h3>
</div>
<ul>
<li>
- <Faq question="What is OpenCode Enterprise?">
- OpenCode Enterprise is for organizations that want to ensure that their code and data never leaves
- their infrastructure. It can do this by using a centralized config that integrates with your SSO and
- internal AI gateway.
- </Faq>
+ <Faq question={i18n.t("enterprise.faq.q1")}>{i18n.t("enterprise.faq.a1")}</Faq>
</li>
<li>
- <Faq question="How do I get started with OpenCode Enterprise?">
- Simply start with an internal trial with your team. OpenCode by default does not store your code or
- context data, making it easy to get started. Then contact us to discuss pricing and implementation
- options.
- </Faq>
+ <Faq question={i18n.t("enterprise.faq.q2")}>{i18n.t("enterprise.faq.a2")}</Faq>
</li>
<li>
- <Faq question="How does enterprise pricing work?">
- We offer per-seat enterprise pricing. If you have your own LLM gateway, we do not charge for tokens
- used. For further details, contact us for a custom quote based on your organization's needs.
- </Faq>
+ <Faq question={i18n.t("enterprise.faq.q3")}>{i18n.t("enterprise.faq.a3")}</Faq>
</li>
<li>
- <Faq question="Is my data secure with OpenCode Enterprise?">
- Yes. OpenCode does not store your code or context data. All processing happens locally or through
- direct API calls to your AI provider. With central config and SSO integration, your data remains
- secure within your organization's infrastructure.
- </Faq>
+ <Faq question={i18n.t("enterprise.faq.q4")}>{i18n.t("enterprise.faq.a4")}</Faq>
</li>
</ul>
</section>
diff --git a/packages/console/app/src/routes/index.css b/packages/console/app/src/routes/index.css
index 9df1d85c7..1c734c9d0 100644
--- a/packages/console/app/src/routes/index.css
+++ b/packages/console/app/src/routes/index.css
@@ -259,6 +259,7 @@ body {
display: flex;
align-items: center;
gap: 8px;
+ white-space: nowrap;
@media (max-width: 55rem) {
display: none;
diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx
index 4c5b50a37..a56b2ac62 100644
--- a/packages/console/app/src/routes/index.tsx
+++ b/packages/console/app/src/routes/index.tsx
@@ -14,6 +14,7 @@ import { Legal } from "~/component/legal"
import { github } from "~/lib/github"
import { createMemo } from "solid-js"
import { config } from "~/config"
+import { useI18n } from "~/context/i18n"
function CopyStatus() {
return (
@@ -25,6 +26,7 @@ function CopyStatus() {
}
export default function Home() {
+ const i18n = useI18n()
const githubData = createAsync(() => github())
const release = createMemo(() => githubData()?.release)
@@ -43,7 +45,7 @@ export default function Home() {
return (
<main data-page="opencode">
{/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/}
- <Title>OpenCode | The open source AI coding agent</Title>
+ <Title>{i18n.t("home.title")}</Title>
<Link rel="canonical" href={config.baseUrl} />
<Meta property="og:image" content="/social-share.png" />
<Meta name="twitter:image" content="/social-share.png" />
@@ -53,16 +55,17 @@ export default function Home() {
<div data-component="content">
<section data-component="hero">
<div data-component="desktop-app-banner">
- <span data-slot="badge">New</span>
+ <span data-slot="badge">{i18n.t("home.banner.badge")}</span>
<div data-slot="content">
<span data-slot="text">
- Desktop app available in beta<span data-slot="platforms"> on macOS, Windows, and Linux</span>.
+ {i18n.t("home.banner.text")}
+ <span data-slot="platforms"> {i18n.t("home.banner.platforms")}</span>.
</span>
<a href="/download" data-slot="link">
- Download now
+ {i18n.t("home.banner.downloadNow")}
</a>
<a href="/download" data-slot="link-mobile">
- Download the desktop beta now
+ {i18n.t("home.banner.downloadBetaNow")}
</a>
</div>
</div>
@@ -73,16 +76,16 @@ export default function Home() {
{/* target="_blank">*/}
{/* What’s new in {release()?.name ?? "the latest release"}*/}
{/*</a>*/}
- <h1>The open source AI coding agent</h1>
+ <h1>{i18n.t("home.hero.title")}</h1>
<p>
- Free models included or connect any model from any provider, <span data-slot="br"></span>including
- Claude, GPT, Gemini and more.
+ {i18n.t("home.hero.subtitle.a")} <span data-slot="br"></span>
+ {i18n.t("home.hero.subtitle.b")}
</p>
</div>
<div data-slot="installation">
<Tabs
as="section"
- aria-label="Install options"
+ aria-label={i18n.t("home.install.ariaLabel")}
class="tabs"
data-component="tabs"
data-active="curl"
@@ -161,61 +164,61 @@ export default function Home() {
<section data-component="video">
<video src={video} autoplay playsinline loop muted preload="auto" poster={videoPoster}>
- Your browser does not support the video tag.
+ {i18n.t("common.videoUnsupported")}
</video>
</section>
<section data-component="what">
<div data-slot="section-title">
- <h3>What is OpenCode?</h3>
- <p>OpenCode is an open source agent that helps you write code in your terminal, IDE, or desktop.</p>
+ <h3>{i18n.t("home.what.title")}</h3>
+ <p>{i18n.t("home.what.body")}</p>
</div>
<ul>
<li>
<span>[*]</span>
<div>
- <strong>LSP enabled</strong> Automatically loads the right LSPs for the LLM
+ <strong>{i18n.t("home.what.lsp.title")}</strong> {i18n.t("home.what.lsp.body")}
</div>
</li>
<li>
<span>[*]</span>
<div>
- <strong>Multi-session</strong> Start multiple agents in parallel on the same project
+ <strong>{i18n.t("home.what.multiSession.title")}</strong> {i18n.t("home.what.multiSession.body")}
</div>
</li>
<li>
<span>[*]</span>
<div>
- <strong>Share links</strong> Share a link to any session for reference or to debug
+ <strong>{i18n.t("home.what.shareLinks.title")}</strong> {i18n.t("home.what.shareLinks.body")}
</div>
</li>
<li>
<span>[*]</span>
<div>
- <strong>GitHub Copilot</strong> Log in with GitHub to use your Copilot account
+ <strong>{i18n.t("home.what.copilot.title")}</strong> {i18n.t("home.what.copilot.body")}
</div>
</li>
<li>
<span>[*]</span>
<div>
- <strong>ChatGPT Plus/Pro</strong> Log in with OpenAI to use your ChatGPT Plus or Pro account
+ <strong>{i18n.t("home.what.chatgptPlus.title")}</strong> {i18n.t("home.what.chatgptPlus.body")}
</div>
</li>
<li>
<span>[*]</span>
<div>
- <strong>Any model</strong> 75+ LLM providers through Models.dev, including local models
+ <strong>{i18n.t("home.what.anyModel.title")}</strong> {i18n.t("home.what.anyModel.body")}
</div>
</li>
<li>
<span>[*]</span>
<div>
- <strong>Any editor</strong> Available as a terminal interface, desktop app, and IDE extension
+ <strong>{i18n.t("home.what.anyEditor.title")}</strong> {i18n.t("home.what.anyEditor.body")}
</div>
</li>
</ul>
<a href="/docs">
- <span>Read docs </span>
+ <span>{i18n.t("home.what.readDocs")} </span>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.5 12L17 12M13 16.5L17.5 12L13 7.5"
@@ -229,15 +232,17 @@ export default function Home() {
<section data-component="growth">
<div data-slot="section-title">
- <h3>The open source AI coding agent</h3>
+ <h3>{i18n.t("home.growth.title")}</h3>
<div>
<span>[*]</span>
- <p>
- With over <strong>{config.github.starsFormatted.full}</strong> GitHub stars,{" "}
- <strong>{config.stats.contributors}</strong> contributors, and over{" "}
- <strong>{config.stats.commits}</strong> commits, OpenCode is used and trusted by over{" "}
- <strong>{config.stats.monthlyUsers}</strong> developers every month.
- </p>
+ <p
+ innerHTML={i18n.t("home.growth.body", {
+ stars: config.github.starsFormatted.full,
+ contributors: config.stats.contributors,
+ commits: config.stats.commits,
+ monthlyUsers: config.stats.monthlyUsers,
+ })}
+ />
</div>
<div data-component="growth-stats">
@@ -289,7 +294,8 @@ export default function Home() {
</svg>
</div>
<span>
- <figure>Fig 1.</figure> <strong>{config.github.starsFormatted.compact}</strong> GitHub Stars
+ <figure>{i18n.t("common.figure", { n: 1 })}</figure>{" "}
+ <strong>{config.github.starsFormatted.compact}</strong> {i18n.t("home.growth.githubStars")}
</span>
</div>
@@ -592,7 +598,8 @@ export default function Home() {
</svg>
</div>
<span>
- <figure>Fig 2.</figure> <strong>{config.stats.contributors}</strong> Contributors
+ <figure>{i18n.t("common.figure", { n: 2 })}</figure> <strong>{config.stats.contributors}</strong>{" "}
+ {i18n.t("home.growth.contributors")}
</span>
</div>
@@ -634,7 +641,8 @@ export default function Home() {
</svg>
</div>
<span>
- <figure>Fig 3.</figure> <strong>{config.stats.monthlyUsers}</strong> Monthly Devs
+ <figure>{i18n.t("common.figure", { n: 3 })}</figure> <strong>{config.stats.monthlyUsers}</strong>{" "}
+ {i18n.t("home.growth.monthlyDevs")}
</span>
</div>
</div>
@@ -643,13 +651,13 @@ export default function Home() {
<section data-component="privacy">
<div data-slot="privacy-title">
- <h3>Built for privacy first</h3>
+ <h3>{i18n.t("home.privacy.title")}</h3>
<div>
<span>[*]</span>
<p>
- OpenCode does not store any of your code or context data, so that it can operate in privacy sensitive
- environments. Learn more about <a href="/docs/enterprise/ ">privacy</a>.
+ {i18n.t("home.privacy.body")} {i18n.t("home.privacy.learnMore")}{" "}
+ <a href="/docs/enterprise/">{i18n.t("home.privacy.link")}</a>.
</p>
</div>
</div>
@@ -657,70 +665,59 @@ export default function Home() {
<section data-component="faq">
<div data-slot="section-title">
- <h3>FAQ</h3>
+ <h3>{i18n.t("common.faq")}</h3>
</div>
<ul>
<li>
- <Faq question="What is OpenCode?">
- OpenCode is an open source agent that helps you write and run code with any AI model. It's available
- as a terminal-based interface, desktop app, or IDE extension.
- </Faq>
+ <Faq question={i18n.t("home.faq.q1")}>{i18n.t("home.faq.a1")}</Faq>
</li>
<li>
- <Faq question="How do I use OpenCode?">
- The easiest way to get started is to read the <a href="/docs">intro</a>.
+ <Faq question={i18n.t("home.faq.q2")}>
+ {i18n.t("home.faq.a2.before")} <a href="/docs">{i18n.t("home.faq.a2.link")}</a>.
</Faq>
</li>
<li>
- <Faq question="Do I need extra AI subscriptions to use OpenCode?">
- Not necessarily, OpenCode comes with a set of free models that you can use without creating an
- account. Aside from these, you can use any of the popular coding models by creating a{" "}
- <A href="/zen">Zen</A> account. While we encourage users to use Zen, OpenCode also works with all
- popular providers such as OpenAI, Anthropic, xAI etc. You can even connect your{" "}
+ <Faq question={i18n.t("home.faq.q3")}>
+ {i18n.t("home.faq.a3.p1")} {i18n.t("home.faq.a3.p2.beforeZen")} <A href="/zen">{i18n.t("nav.zen")}</A>
+ {i18n.t("home.faq.a3.p2.afterZen")} {i18n.t("home.faq.a3.p3")} {i18n.t("home.faq.a3.p4.beforeLocal")}{" "}
<a href="/docs/providers/#lm-studio" target="_blank">
- local models
+ {i18n.t("home.faq.a3.p4.localLink")}
</a>
.
</Faq>
</li>
<li>
- <Faq question="Can I use my existing AI subscriptions with OpenCode?">
- Yes, OpenCode supports subscription plans from all major providers. You can use your Claude Pro/Max,
- ChatGPT Plus/Pro, or GitHub Copilot subscriptions. <a href="/docs/providers/#directory">Learn more</a>
- .
+ <Faq question={i18n.t("home.faq.q4")}>
+ {i18n.t("home.faq.a4.p1")} <a href="/docs/providers/#directory">{i18n.t("common.learnMore")}</a>.
</Faq>
</li>
<li>
- <Faq question="Can I only use OpenCode in the terminal?">
- Not anymore! OpenCode is now available as an app for your <a href="/download">desktop</a> and{" "}
- <a href="/docs/web">web</a>!
+ <Faq question={i18n.t("home.faq.q5")}>
+ {i18n.t("home.faq.a5.beforeDesktop")} <a href="/download">{i18n.t("home.faq.a5.desktop")}</a>{" "}
+ {i18n.t("home.faq.a5.and")} <a href="/docs/web">{i18n.t("home.faq.a5.web")}</a>!
</Faq>
</li>
<li>
- <Faq question="How much does OpenCode cost?">
- OpenCode is 100% free to use. It also comes with a set of free models. There might be additional costs
- if you connect any other provider.
- </Faq>
+ <Faq question={i18n.t("home.faq.q6")}>{i18n.t("home.faq.a6")}</Faq>
</li>
<li>
- <Faq question="What about data and privacy?">
- Your data and information is only stored when you use our free models or create sharable links. Learn
- more about <a href="/docs/zen/#privacy">our models</a> and{" "}
- <a href="/docs/share/#privacy">share pages</a>.
+ <Faq question={i18n.t("home.faq.q7")}>
+ {i18n.t("home.faq.a7.p1")} {i18n.t("home.faq.a7.p2.beforeModels")}{" "}
+ <a href="/docs/zen/#privacy">{i18n.t("home.faq.a7.p2.modelsLink")}</a> {i18n.t("home.faq.a7.p2.and")}{" "}
+ <a href="/docs/share/#privacy">{i18n.t("home.faq.a7.p2.shareLink")}</a>.
</Faq>
</li>
<li>
- <Faq question="Is OpenCode open source?">
- Yes, OpenCode is fully open source. The source code is public on{" "}
+ <Faq question={i18n.t("home.faq.q8")}>
+ {i18n.t("home.faq.a8.p1")}{" "}
<a href={config.github.repoUrl} target="_blank">
- GitHub
+ {i18n.t("nav.github")}
</a>{" "}
- under the{" "}
+ {i18n.t("home.faq.a8.p2")}{" "}
<a href={`${config.github.repoUrl}?tab=MIT-1-ov-file#readme`} target="_blank">
- MIT License
+ {i18n.t("home.faq.a8.mitLicense")}
</a>
- , meaning anyone can use, modify, or contribute to its development. Anyone from the community can file
- issues, submit pull requests, and extend functionality.
+ {i18n.t("home.faq.a8.p3")}
</Faq>
</li>
</ul>
@@ -728,12 +725,8 @@ export default function Home() {
<section data-component="zen-cta">
<div data-slot="zen-cta-copy">
- <strong>Access reliable optimized models for coding agents</strong>
- <p>
- Zen gives you access to a handpicked set of AI models that OpenCode has tested and benchmarked
- specifically for coding agents. No need to worry about inconsistent performance and quality across
- providers, use validated models that work.
- </p>
+ <strong>{i18n.t("home.zenCta.title")}</strong>
+ <p>{i18n.t("home.zenCta.body")}</p>
<div data-slot="model-logos">
<div>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -816,7 +809,7 @@ export default function Home() {
</div>
</div>
<A href="/zen">
- <span>Learn about Zen </span>
+ <span>{i18n.t("home.zenCta.link")} </span>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.5 12L17 12M13 16.5L17.5 12L13 7.5"
diff --git a/packages/console/app/src/routes/s/[id].ts b/packages/console/app/src/routes/s/[id].ts
index 3fd1305a0..3cdc486a4 100644
--- a/packages/console/app/src/routes/s/[id].ts
+++ b/packages/console/app/src/routes/s/[id].ts
@@ -1,12 +1,18 @@
import type { APIEvent } from "@solidjs/start/server"
+import { localeFromCookieHeader, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
const url = new URL(req.url)
const targetUrl = `https://docs.opencode.ai/docs${url.pathname}${url.search}`
+
+ const headers = new Headers(req.headers)
+ const locale = localeFromCookieHeader(req.headers.get("cookie"))
+ if (locale) headers.set("accept-language", tag(locale))
+
const response = await fetch(targetUrl, {
method: req.method,
- headers: req.headers,
+ headers,
body: req.body,
})
return response
diff --git a/packages/console/app/src/routes/t/[...path].tsx b/packages/console/app/src/routes/t/[...path].tsx
index b877a8d58..5cb412f78 100644
--- a/packages/console/app/src/routes/t/[...path].tsx
+++ b/packages/console/app/src/routes/t/[...path].tsx
@@ -1,12 +1,18 @@
import type { APIEvent } from "@solidjs/start/server"
+import { localeFromCookieHeader, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
const url = new URL(req.url)
const targetUrl = `https://enterprise.opencode.ai/${url.pathname}${url.search}`
+
+ const headers = new Headers(req.headers)
+ const locale = localeFromCookieHeader(req.headers.get("cookie"))
+ if (locale) headers.set("accept-language", tag(locale))
+
const response = await fetch(targetUrl, {
method: req.method,
- headers: req.headers,
+ headers,
body: req.body,
})
return response
diff --git a/packages/console/app/src/routes/temp.tsx b/packages/console/app/src/routes/temp.tsx
index ccd956810..d6d890479 100644
--- a/packages/console/app/src/routes/temp.tsx
+++ b/packages/console/app/src/routes/temp.tsx
@@ -5,6 +5,7 @@ import logoLight from "../asset/logo-ornate-light.svg"
import logoDark from "../asset/logo-ornate-dark.svg"
import IMG_SPLASH from "../asset/lander/screenshot-splash.png"
import { IconCopy, IconCheck } from "../component/icon"
+import { useI18n } from "~/context/i18n"
function CopyStatus() {
return (
@@ -16,6 +17,8 @@ function CopyStatus() {
}
export default function Home() {
+ const i18n = useI18n()
+
onMount(() => {
const commands = document.querySelectorAll("[data-copy]")
for (const button of commands) {
@@ -38,24 +41,24 @@ export default function Home() {
return (
<main data-page="home">
- <Title>opencode | AI coding agent built for the terminal</Title>
+ <Title>{i18n.t("temp.title")}</Title>
<div data-component="content">
<section data-component="top">
<img data-slot="logo light" src={logoLight} alt="opencode logo light" />
<img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
- <h1 data-slot="title">The AI coding agent built for the terminal</h1>
+ <h1 data-slot="title">{i18n.t("temp.hero.title")}</h1>
<div data-slot="login">
- <a href="/auth">opencode zen</a>
+ <a href="/auth">{i18n.t("temp.zen")}</a>
</div>
</section>
<section data-component="cta">
<div data-slot="left">
- <a href="/docs">Get Started</a>
+ <a href="/docs">{i18n.t("temp.getStarted")}</a>
</div>
<div data-slot="center">
- <a href="/auth">opencode zen</a>
+ <a href="/auth">{i18n.t("temp.zen")}</a>
</div>
<div data-slot="right">
<button data-copy data-slot="command">
@@ -73,30 +76,32 @@ export default function Home() {
<section data-component="features">
<ul data-slot="list">
<li>
- <strong>Native TUI</strong> A responsive, native, themeable terminal UI
+ <strong>{i18n.t("temp.feature.native.title")}</strong> {i18n.t("temp.feature.native.body")}
</li>
<li>
- <strong>LSP enabled</strong> Automatically loads the right LSPs for the LLM
+ <strong>{i18n.t("home.what.lsp.title")}</strong> {i18n.t("home.what.lsp.body")}
</li>
<li>
- <strong>opencode zen</strong> A <a href="/docs/zen">curated list of models</a> provided by opencode{" "}
- <label>New</label>
+ <strong>{i18n.t("temp.zen")}</strong> {i18n.t("temp.feature.zen.beforeLink")}{" "}
+ <a href="/docs/zen">{i18n.t("temp.feature.zen.link")}</a> {i18n.t("temp.feature.zen.afterLink")}{" "}
+ <label>{i18n.t("home.banner.badge")}</label>
</li>
<li>
- <strong>Multi-session</strong> Start multiple agents in parallel on the same project
+ <strong>{i18n.t("home.what.multiSession.title")}</strong> {i18n.t("home.what.multiSession.body")}
</li>
<li>
- <strong>Shareable links</strong> Share a link to any sessions for reference or to debug
+ <strong>{i18n.t("home.what.shareLinks.title")}</strong> {i18n.t("home.what.shareLinks.body")}
</li>
<li>
- <strong>GitHub Copilot</strong> Log in with GitHub to use your Copilot account
+ <strong>{i18n.t("home.what.copilot.title")}</strong> {i18n.t("home.what.copilot.body")}
</li>
<li>
- <strong>ChatGPT Plus/Pro</strong> Log in with OpenAI to use your ChatGPT Plus or Pro account
+ <strong>{i18n.t("home.what.chatgptPlus.title")}</strong> {i18n.t("home.what.chatgptPlus.body")}
</li>
<li>
- <strong>Use any model</strong> Supports 75+ LLM providers through{" "}
- <a href="https://models.dev">Models.dev</a>, including local models
+ <strong>{i18n.t("home.what.anyModel.title")}</strong> {i18n.t("temp.feature.models.beforeLink")}{" "}
+ <a href="https://models.dev">Models.dev</a>
+ {i18n.t("temp.feature.models.afterLink")}
</li>
</ul>
</section>
@@ -142,22 +147,22 @@ export default function Home() {
<section data-component="screenshots">
<figure>
- <figcaption>opencode TUI with the tokyonight theme</figcaption>
+ <figcaption>{i18n.t("temp.screenshot.caption")}</figcaption>
<a href="/docs/cli">
- <img src={IMG_SPLASH} alt="opencode TUI with tokyonight theme" />
+ <img src={IMG_SPLASH} alt={i18n.t("temp.screenshot.alt")} />
</a>
</figure>
</section>
<footer data-component="footer">
<div data-slot="cell">
- <a href="https://x.com/opencode">X.com</a>
+ <a href="https://x.com/opencode">{i18n.t("footer.x")}</a>
</div>
<div data-slot="cell">
- <a href="https://github.com/anomalyco/opencode">GitHub</a>
+ <a href="https://github.com/anomalyco/opencode">{i18n.t("footer.github")}</a>
</div>
<div data-slot="cell">
- <a href="https://opencode.ai/discord">Discord</a>
+ <a href="https://opencode.ai/discord">{i18n.t("footer.discord")}</a>
</div>
</footer>
</div>
diff --git a/packages/console/app/src/routes/user-menu.tsx b/packages/console/app/src/routes/user-menu.tsx
index a910c2efd..f089aad81 100644
--- a/packages/console/app/src/routes/user-menu.tsx
+++ b/packages/console/app/src/routes/user-menu.tsx
@@ -2,6 +2,7 @@ import { action } from "@solidjs/router"
import { getRequestEvent } from "solid-js/web"
import { useAuthSession } from "~/context/auth"
import { Dropdown } from "~/component/dropdown"
+import { useI18n } from "~/context/i18n"
import "./user-menu.css"
const logout = action(async () => {
@@ -20,11 +21,12 @@ const logout = action(async () => {
}, "auth.logout")
export function UserMenu(props: { email: string | null | undefined }) {
+ const i18n = useI18n()
return (
<div data-component="user-menu">
<Dropdown trigger={props.email ?? ""} align="right">
<a href="/auth/logout" data-slot="item">
- Logout
+ {i18n.t("user.logout")}
</a>
</Dropdown>
</div>
diff --git a/packages/console/app/src/routes/workspace-picker.tsx b/packages/console/app/src/routes/workspace-picker.tsx
index 2ba750cea..ffec2f3be 100644
--- a/packages/console/app/src/routes/workspace-picker.tsx
+++ b/packages/console/app/src/routes/workspace-picker.tsx
@@ -9,6 +9,7 @@ import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { Workspace } from "@opencode-ai/console-core/workspace.js"
import { Dropdown, DropdownItem } from "~/component/dropdown"
import { Modal } from "~/component/modal"
+import { useI18n } from "~/context/i18n"
import "./workspace-picker.css"
const getWorkspaces = query(async () => {
@@ -47,6 +48,7 @@ const createWorkspace = action(async (form: FormData) => {
export function WorkspacePicker() {
const params = useParams()
+ const i18n = useI18n()
const workspaces = createAsync(() => getWorkspaces())
const submission = useSubmission(createWorkspace)
const [store, setStore] = createStore({
@@ -56,7 +58,7 @@ export function WorkspacePicker() {
const currentWorkspace = () => {
const ws = workspaces()?.find((w) => w.id === params.id)
- return ws ? ws.name : "Select workspace"
+ return ws ? ws.name : i18n.t("workspace.select")
}
const handleWorkspaceNew = () => {
@@ -91,11 +93,11 @@ export function WorkspacePicker() {
)}
</For>
<button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}>
- + Create New Workspace
+ {i18n.t("workspace.createNew")}
</button>
</Dropdown>
- <Modal open={store.showForm} onClose={() => setStore("showForm", false)} title="Create New Workspace">
+ <Modal open={store.showForm} onClose={() => setStore("showForm", false)} title={i18n.t("workspace.modal.title")}>
<form data-slot="create-form" action={createWorkspace} method="post">
<div data-slot="create-input-group">
<input
@@ -103,15 +105,15 @@ export function WorkspacePicker() {
data-slot="create-input"
type="text"
name="workspaceName"
- placeholder="Enter workspace name"
+ placeholder={i18n.t("workspace.modal.placeholder")}
required
/>
<div data-slot="button-group">
<button type="button" data-color="ghost" onClick={() => setStore("showForm", false)}>
- Cancel
+ {i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
- {submission.pending ? "Creating..." : "Create"}
+ {submission.pending ? i18n.t("common.creating") : i18n.t("common.create")}
</button>
</div>
</div>
diff --git a/packages/console/app/src/routes/workspace/[id].css b/packages/console/app/src/routes/workspace/[id].css
index a83e852b5..5d19988b3 100644
--- a/packages/console/app/src/routes/workspace/[id].css
+++ b/packages/console/app/src/routes/workspace/[id].css
@@ -129,12 +129,41 @@
flex: 1;
padding: var(--space-6) var(--space-8);
overflow-y: auto;
+ display: flex;
+ flex-direction: column;
@media (max-width: 48rem) {
padding: var(--space-6) var(--space-4);
}
}
+[data-component="workspace-main"] {
+ flex: 1;
+}
+
+[data-component="workspace-content"] > [data-component="legal"] {
+ margin-top: var(--space-16);
+ padding-top: var(--space-8);
+ border-top: 1px solid var(--color-border);
+ color: var(--color-text-weak);
+ display: flex;
+ gap: var(--space-8);
+
+ a {
+ color: var(--color-text-weak);
+ text-decoration: none;
+ }
+
+ a:hover {
+ color: var(--color-text);
+ text-decoration: underline;
+ }
+
+ @media (max-width: 48rem) {
+ gap: var(--space-4);
+ }
+}
+
[data-page="workspace-[id]"] {
max-width: 64rem;
padding: var(--space-2) var(--space-4);
diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx
index d81200b9b..fde074e5e 100644
--- a/packages/console/app/src/routes/workspace/[id].tsx
+++ b/packages/console/app/src/routes/workspace/[id].tsx
@@ -2,9 +2,12 @@ import { Show } from "solid-js"
import { createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
import { querySessionInfo } from "./common"
import "./[id].css"
+import { useI18n } from "~/context/i18n"
+import { Legal } from "~/component/legal"
export default function WorkspaceLayout(props: RouteSectionProps) {
const params = useParams()
+ const i18n = useI18n()
const userInfo = createAsync(() => querySessionInfo(params.id!))
return (
@@ -14,20 +17,20 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
<nav data-component="nav-desktop">
<div data-component="workspace-nav-items">
<A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
- Zen
+ {i18n.t("workspace.nav.zen")}
</A>
<A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
- API Keys
+ {i18n.t("workspace.nav.apiKeys")}
</A>
<A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
- Members
+ {i18n.t("workspace.nav.members")}
</A>
<Show when={userInfo()?.isAdmin}>
<A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
- Billing
+ {i18n.t("workspace.nav.billing")}
</A>
<A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
- Settings
+ {i18n.t("workspace.nav.settings")}
</A>
</Show>
</div>
@@ -36,26 +39,29 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
<nav data-component="nav-mobile">
<div data-component="workspace-nav-items">
<A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
- Zen
+ {i18n.t("workspace.nav.zen")}
</A>
<A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
- API Keys
+ {i18n.t("workspace.nav.apiKeys")}
</A>
<A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
- Members
+ {i18n.t("workspace.nav.members")}
</A>
<Show when={userInfo()?.isAdmin}>
<A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
- Billing
+ {i18n.t("workspace.nav.billing")}
</A>
<A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
- Settings
+ {i18n.t("workspace.nav.settings")}
</A>
</Show>
</div>
</nav>
</nav>
- <div data-component="workspace-content">{props.children}</div>
+ <div data-component="workspace-content">
+ <div data-component="workspace-main">{props.children}</div>
+ <Legal />
+ </div>
</div>
</main>
)
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
index e8403f9ae..db89a1c9e 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
@@ -6,6 +6,8 @@ import { withActor } from "~/context/auth.withActor"
import { IconCreditCard, IconStripe } from "~/component/icon"
import styles from "./billing-section.module.css"
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
+import { useI18n } from "~/context/i18n"
+import { localizeError } from "~/lib/form-error"
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
@@ -26,6 +28,7 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
export function BillingSection() {
const params = useParams()
+ const i18n = useI18n()
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const checkoutAction = useAction(createCheckoutUrl)
@@ -137,16 +140,18 @@ export function BillingSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
- <h2>Billing</h2>
+ <h2>{i18n.t("workspace.billing.title")}</h2>
<p>
- Manage payments methods. <a href="mailto:[email protected]">Contact us</a> if you have any questions.
+ {i18n.t("workspace.billing.subtitle.beforeLink")}{" "}
+ <a href="mailto:[email protected]">{i18n.t("workspace.billing.contactUs")}</a>{" "}
+ {i18n.t("workspace.billing.subtitle.afterLink")}
</p>
</div>
<div data-slot="section-content">
<div data-slot="balance-display">
<div data-slot="balance-amount">
<span data-slot="balance-value">${balance()}</span>
- <span data-slot="balance-label">Current Balance</span>
+ <span data-slot="balance-label">{i18n.t("workspace.billing.currentBalance")}</span>
</div>
<Show when={billingInfo()?.customerID}>
<div data-slot="balance-right-section">
@@ -155,7 +160,7 @@ export function BillingSection() {
fallback={
<div data-slot="add-balance-form-container">
<div data-slot="add-balance-form">
- <label>Add $</label>
+ <label>{i18n.t("workspace.billing.add")}</label>
<input
data-component="input"
type="number"
@@ -166,11 +171,11 @@ export function BillingSection() {
setStore("addBalanceAmount", e.currentTarget.value)
checkoutSubmission.clear()
}}
- placeholder="Enter amount"
+ placeholder={i18n.t("workspace.billing.enterAmount")}
/>
<div data-slot="form-actions">
<button data-color="ghost" type="button" onClick={() => hideAddBalanceForm()}>
- Cancel
+ {i18n.t("common.cancel")}
</button>
<button
data-color="primary"
@@ -178,18 +183,20 @@ export function BillingSection() {
disabled={!store.addBalanceAmount || checkoutSubmission.pending || store.checkoutRedirecting}
onClick={onClickCheckout}
>
- {checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Add"}
+ {checkoutSubmission.pending || store.checkoutRedirecting
+ ? i18n.t("workspace.billing.loading")
+ : i18n.t("workspace.billing.addAction")}
</button>
</div>
</div>
<Show when={checkoutSubmission.result && (checkoutSubmission.result as any).error}>
- {(err: any) => <div data-slot="form-error">{err()}</div>}
+ {(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</div>
}
>
<button data-color="primary" onClick={() => showAddBalanceForm()}>
- Add Balance
+ {i18n.t("workspace.billing.addBalance")}
</button>
</Show>
<div data-slot="credit-card">
@@ -209,7 +216,7 @@ export function BillingSection() {
</Show>
</Match>
<Match when={billingInfo()?.paymentMethodType === "link"}>
- <span data-slot="type">Linked to Stripe</span>
+ <span data-slot="type">{i18n.t("workspace.billing.linkedToStripe")}</span>
</Match>
</Switch>
</div>
@@ -218,7 +225,9 @@ export function BillingSection() {
disabled={sessionSubmission.pending || store.sessionRedirecting}
onClick={onClickSession}
>
- {sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage"}
+ {sessionSubmission.pending || store.sessionRedirecting
+ ? i18n.t("workspace.billing.loading")
+ : i18n.t("workspace.billing.manage")}
</button>
</div>
</div>
@@ -231,7 +240,9 @@ export function BillingSection() {
disabled={checkoutSubmission.pending || store.checkoutRedirecting}
onClick={onClickCheckout}
>
- {checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable Billing"}
+ {checkoutSubmission.pending || store.checkoutRedirecting
+ ? i18n.t("workspace.billing.loading")
+ : i18n.t("workspace.billing.enable")}
</button>
</Show>
</div>
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx
index 6d18f0a2f..ce0eb2c6a 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx
@@ -10,6 +10,8 @@ import { withActor } from "~/context/auth.withActor"
import { queryBillingInfo } from "../../common"
import styles from "./black-section.module.css"
import waitlistStyles from "./black-waitlist-section.module.css"
+import { useI18n } from "~/context/i18n"
+import { formError } from "~/lib/form-error"
const querySubscription = query(async (workspaceID: string) => {
"use server"
@@ -47,17 +49,18 @@ const querySubscription = query(async (workspaceID: string) => {
}, workspaceID)
}, "subscription.get")
-function formatResetTime(seconds: number) {
+function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
- return `${days} ${days === 1 ? "day" : "days"} ${hours} ${hours === 1 ? "hour" : "hours"}`
+ return `${days} ${days === 1 ? i18n.t("workspace.black.time.day") : i18n.t("workspace.black.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.time.hours")}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
- if (hours >= 1) return `${hours} ${hours === 1 ? "hour" : "hours"} ${minutes} ${minutes === 1 ? "minute" : "minutes"}`
- if (minutes === 0) return "a few seconds"
- return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`
+ if (hours >= 1)
+ return `${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
+ if (minutes === 0) return i18n.t("workspace.black.time.fewSeconds")
+ return `${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
}
const cancelWaitlist = action(async (workspaceID: string) => {
@@ -111,7 +114,7 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
const setUseBalance = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
+ if (!workspaceID) return { error: formError.workspaceRequired }
const useBalance = form.get("useBalance")?.toString() === "true"
return json(
@@ -134,6 +137,7 @@ const setUseBalance = action(async (form: FormData) => {
export function BlackSection() {
const params = useParams()
+ const i18n = useI18n()
const billing = createAsync(() => queryBillingInfo(params.id!))
const subscription = createAsync(() => querySubscription(params.id!))
const sessionAction = useAction(createSessionUrl)
@@ -177,42 +181,50 @@ export function BlackSection() {
{(sub) => (
<section class={styles.root}>
<div data-slot="section-title">
- <h2>Subscription</h2>
+ <h2>{i18n.t("workspace.black.subscription.title")}</h2>
<div data-slot="title-row">
- <p>You are subscribed to OpenCode Black for ${sub().plan} per month.</p>
+ <p>{i18n.t("workspace.black.subscription.message", { plan: sub().plan })}</p>
<button
data-color="primary"
disabled={sessionSubmission.pending || store.sessionRedirecting}
onClick={onClickSession}
>
- {sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage Subscription"}
+ {sessionSubmission.pending || store.sessionRedirecting
+ ? i18n.t("workspace.black.loading")
+ : i18n.t("workspace.black.subscription.manage")}
</button>
</div>
</div>
<div data-slot="usage">
<div data-slot="usage-item">
<div data-slot="usage-header">
- <span data-slot="usage-label">5-hour Usage</span>
+ <span data-slot="usage-label">{i18n.t("workspace.black.subscription.rollingUsage")}</span>
<span data-slot="usage-value">{sub().rollingUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().rollingUsage.usagePercent}%` }} />
</div>
- <span data-slot="reset-time">Resets in {formatResetTime(sub().rollingUsage.resetInSec)}</span>
+ <span data-slot="reset-time">
+ {i18n.t("workspace.black.subscription.resetsIn")}{" "}
+ {formatResetTime(sub().rollingUsage.resetInSec, i18n)}
+ </span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
- <span data-slot="usage-label">Weekly Usage</span>
+ <span data-slot="usage-label">{i18n.t("workspace.black.subscription.weeklyUsage")}</span>
<span data-slot="usage-value">{sub().weeklyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().weeklyUsage.usagePercent}%` }} />
</div>
- <span data-slot="reset-time">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
+ <span data-slot="reset-time">
+ {i18n.t("workspace.black.subscription.resetsIn")}{" "}
+ {formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
+ </span>
</div>
</div>
<form action={setUseBalance} method="post" data-slot="setting-row">
- <p>Use your available balance after reaching the usage limits</p>
+ <p>{i18n.t("workspace.black.subscription.useBalance")}</p>
<input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="useBalance" value={sub().useBalance ? "false" : "true"} />
<label data-slot="toggle-label">
@@ -231,19 +243,23 @@ export function BlackSection() {
<Show when={billing()?.timeSubscriptionBooked}>
<section class={waitlistStyles.root}>
<div data-slot="section-title">
- <h2>Waitlist</h2>
+ <h2>{i18n.t("workspace.black.waitlist.title")}</h2>
<div data-slot="title-row">
<p>
{billing()?.timeSubscriptionSelected
- ? `We're ready to enroll you into the $${billing()?.subscriptionPlan} per month OpenCode Black plan.`
- : `You are on the waitlist for the $${billing()?.subscriptionPlan} per month OpenCode Black plan.`}
+ ? i18n.t("workspace.black.waitlist.ready", { plan: billing()?.subscriptionPlan ?? "" })
+ : i18n.t("workspace.black.waitlist.joined", { plan: billing()?.subscriptionPlan ?? "" })}
</p>
<button
data-color="danger"
disabled={cancelSubmission.pending || store.cancelled}
onClick={onClickCancel}
>
- {cancelSubmission.pending ? "Leaving..." : store.cancelled ? "Left" : "Leave Waitlist"}
+ {cancelSubmission.pending
+ ? i18n.t("workspace.black.waitlist.leaving")
+ : store.cancelled
+ ? i18n.t("workspace.black.waitlist.left")
+ : i18n.t("workspace.black.waitlist.leave")}
</button>
</div>
</div>
@@ -255,11 +271,13 @@ export function BlackSection() {
disabled={enrollSubmission.pending || store.enrolled}
onClick={onClickEnroll}
>
- {enrollSubmission.pending ? "Enrolling..." : store.enrolled ? "Enrolled" : "Enroll"}
+ {enrollSubmission.pending
+ ? i18n.t("workspace.black.waitlist.enrolling")
+ : store.enrolled
+ ? i18n.t("workspace.black.waitlist.enrolled")
+ : i18n.t("workspace.black.waitlist.enroll")}
</button>
- <p data-slot="enroll-note">
- When you click Enroll, your subscription starts immediately and your card will be charged.
- </p>
+ <p data-slot="enroll-note">{i18n.t("workspace.black.waitlist.enrollNote")}</p>
</div>
</Show>
</section>
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx
index 9eb4758a3..ef54b8409 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx
@@ -5,15 +5,17 @@ import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import styles from "./monthly-limit-section.module.css"
import { queryBillingInfo } from "../../common"
+import { useI18n } from "~/context/i18n"
+import { formError, localizeError } from "~/lib/form-error"
const setMonthlyLimit = action(async (form: FormData) => {
"use server"
const limit = form.get("limit")?.toString()
- if (!limit) return { error: "Limit is required." }
+ if (!limit) return { error: formError.limitRequired }
const numericLimit = parseInt(limit)
- if (numericLimit < 0) return { error: "Set a valid monthly limit." }
+ if (numericLimit < 0) return { error: formError.monthlyLimitInvalid }
const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required." }
+ if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
@@ -28,6 +30,7 @@ const setMonthlyLimit = action(async (form: FormData) => {
export function MonthlyLimitSection() {
const params = useParams()
+ const i18n = useI18n()
const submission = useSubmission(setMonthlyLimit)
const [store, setStore] = createStore({ show: false })
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
@@ -61,8 +64,8 @@ export function MonthlyLimitSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
- <h2>Monthly Limit</h2>
- <p>Set a monthly usage limit for your account.</p>
+ <h2>{i18n.t("workspace.monthlyLimit.title")}</h2>
+ <p>{i18n.t("workspace.monthlyLimit.subtitle")}</p>
</div>
<div data-slot="section-content">
<div data-slot="balance">
@@ -81,42 +84,51 @@ export function MonthlyLimitSection() {
data-component="input"
name="limit"
type="number"
- placeholder="50"
+ placeholder={i18n.t("workspace.monthlyLimit.placeholder")}
/>
<Show when={submission.result && submission.result.error}>
- {(err) => <div data-slot="form-error">{err()}</div>}
+ {(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
- Cancel
+ {i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
- {submission.pending ? "Setting..." : "Set"}
+ {submission.pending
+ ? i18n.t("workspace.monthlyLimit.setting")
+ : i18n.t("workspace.monthlyLimit.set")}
</button>
</div>
</form>
}
>
<button data-color="primary" onClick={() => show()}>
- {billingInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
+ {billingInfo()?.monthlyLimit
+ ? i18n.t("workspace.monthlyLimit.edit")
+ : i18n.t("workspace.monthlyLimit.set")}
</button>
</Show>
</div>
- <Show when={billingInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No usage limit set.</p>}>
+ <Show
+ when={billingInfo()?.monthlyLimit}
+ fallback={<p data-slot="usage-status">{i18n.t("workspace.monthlyLimit.noLimit")}</p>}
+ >
<p data-slot="usage-status">
- Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
+ {i18n.t("workspace.monthlyLimit.currentUsage.beforeMonth")}{" "}
+ {new Date().toLocaleDateString(undefined, { month: "long", timeZone: "UTC" })}{" "}
+ {i18n.t("workspace.monthlyLimit.currentUsage.beforeAmount")}
{(() => {
const dateLastUsed = billingInfo()?.timeMonthlyUsageUpdated
if (!dateLastUsed) return "0"
- const current = new Date().toLocaleDateString("en-US", {
+ const current = new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
timeZone: "UTC",
})
- const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
+ const lastUsed = dateLastUsed.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
timeZone: "UTC",
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
index d21c4edce..2311be321 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
@@ -4,6 +4,7 @@ import { For, Match, Show, Switch } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./payment-section.module.css"
+import { useI18n } from "~/context/i18n"
const getPaymentsInfo = query(async (workspaceID: string) => {
"use server"
@@ -19,6 +20,7 @@ const downloadReceipt = action(async (workspaceID: string, paymentID: string) =>
export function PaymentSection() {
const params = useParams()
+ const i18n = useI18n()
const payments = createAsync(() => getPaymentsInfo(params.id!))
const downloadReceiptAction = useAction(downloadReceipt)
@@ -60,17 +62,17 @@ export function PaymentSection() {
<Show when={payments() && payments()!.length > 0}>
<section class={styles.root}>
<div data-slot="section-title">
- <h2>Payments History</h2>
- <p>Recent payment transactions.</p>
+ <h2>{i18n.t("workspace.payments.title")}</h2>
+ <p>{i18n.t("workspace.payments.subtitle")}</p>
</div>
<div data-slot="payments-table">
<table data-slot="payments-table-element">
<thead>
<tr>
- <th>Date</th>
- <th>Payment ID</th>
- <th>Amount</th>
- <th>Receipt</th>
+ <th>{i18n.t("workspace.payments.table.date")}</th>
+ <th>{i18n.t("workspace.payments.table.paymentId")}</th>
+ <th>{i18n.t("workspace.payments.table.amount")}</th>
+ <th>{i18n.t("workspace.payments.table.receipt")}</th>
</tr>
</thead>
<tbody>
@@ -88,8 +90,13 @@ export function PaymentSection() {
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
${((amount ?? 0) / 100000000).toFixed(2)}
<Switch>
- <Match when={payment.enrichment?.type === "credit"}> (credit)</Match>
- <Match when={payment.enrichment?.type === "subscription"}> (subscription)</Match>
+ <Match when={payment.enrichment?.type === "credit"}>
+ {" "}
+ ({i18n.t("workspace.payments.type.credit")})
+ </Match>
+ <Match when={payment.enrichment?.type === "subscription"}>
+ ({i18n.t("workspace.payments.type.subscription")})
+ </Match>
</Switch>
</td>
<td data-slot="payment-receipt">
@@ -103,7 +110,7 @@ export function PaymentSection() {
}}
data-slot="receipt-button"
>
- View
+ {i18n.t("workspace.payments.view")}
</button>
) : (
<span>-</span>
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
index 1f738b875..7084be133 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
@@ -7,11 +7,13 @@ import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import styles from "./reload-section.module.css"
import { queryBillingInfo } from "../../common"
+import { useI18n } from "~/context/i18n"
+import { formError, formErrorReloadAmountMin, formErrorReloadTriggerMin, localizeError } from "~/lib/form-error"
const reload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
+ if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Billing.reload(), workspaceID), {
revalidate: queryBillingInfo.key,
})
@@ -20,7 +22,7 @@ const reload = action(async (form: FormData) => {
const setReload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
+ if (!workspaceID) return { error: formError.workspaceRequired }
const reloadValue = form.get("reload")?.toString() === "true"
const amountStr = form.get("reloadAmount")?.toString()
const triggerStr = form.get("reloadTrigger")?.toString()
@@ -30,9 +32,9 @@ const setReload = action(async (form: FormData) => {
if (reloadValue) {
if (reloadAmount === null || reloadAmount < Billing.RELOAD_AMOUNT_MIN)
- return { error: `Reload amount must be at least $${Billing.RELOAD_AMOUNT_MIN}` }
+ return { error: formErrorReloadAmountMin(Billing.RELOAD_AMOUNT_MIN) }
if (reloadTrigger === null || reloadTrigger < Billing.RELOAD_TRIGGER_MIN)
- return { error: `Balance trigger must be at least $${Billing.RELOAD_TRIGGER_MIN}` }
+ return { error: formErrorReloadTriggerMin(Billing.RELOAD_TRIGGER_MIN) }
}
return json(
@@ -58,6 +60,7 @@ const setReload = action(async (form: FormData) => {
export function ReloadSection() {
const params = useParams()
+ const i18n = useI18n()
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const setReloadSubmission = useSubmission(setReload)
const reloadSubmission = useSubmission(reload)
@@ -99,23 +102,26 @@ export function ReloadSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
- <h2>Auto Reload</h2>
+ <h2>{i18n.t("workspace.reload.title")}</h2>
<div data-slot="title-row">
<Show
when={billingInfo()?.reload}
fallback={
<p>
- Auto reload is <b>disabled</b>. Enable to automatically reload when balance is low.
+ {i18n.t("workspace.reload.disabled.before")} <b>{i18n.t("workspace.reload.disabled.state")}</b>.{" "}
+ {i18n.t("workspace.reload.disabled.after")}
</p>
}
>
<p>
- Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+${processingFee()}{" "}
- processing fee) when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
+ {i18n.t("workspace.reload.enabled.before")} <b>{i18n.t("workspace.reload.enabled.state")}</b>.{" "}
+ {i18n.t("workspace.reload.enabled.middle")} <b>${billingInfo()?.reloadAmount}</b> (+${processingFee()}{" "}
+ {i18n.t("workspace.reload.processingFee")}) {i18n.t("workspace.reload.enabled.after")}{" "}
+ <b>${billingInfo()?.reloadTrigger}</b>.
</p>
</Show>
<button data-color="primary" type="button" onClick={() => show()}>
- {billingInfo()?.reload ? "Edit" : "Enable"}
+ {billingInfo()?.reload ? i18n.t("workspace.reload.edit") : i18n.t("workspace.reload.enable")}
</button>
</div>
</div>
@@ -123,7 +129,7 @@ export function ReloadSection() {
<form action={setReload} method="post" data-slot="create-form">
<div data-slot="form-field">
<label>
- <span data-slot="field-label">Enable Auto Reload</span>
+ <span data-slot="field-label">{i18n.t("workspace.reload.enableAutoReload")}</span>
<div data-slot="toggle-container">
<label data-slot="model-toggle-label">
<input
@@ -141,7 +147,7 @@ export function ReloadSection() {
<div data-slot="input-row">
<div data-slot="input-field">
- <p>Reload $</p>
+ <p>{i18n.t("workspace.reload.reloadAmount")}</p>
<input
data-component="input"
name="reloadAmount"
@@ -155,7 +161,7 @@ export function ReloadSection() {
/>
</div>
<div data-slot="input-field">
- <p>When balance reaches $</p>
+ <p>{i18n.t("workspace.reload.whenBalanceReaches")}</p>
<input
data-component="input"
name="reloadTrigger"
@@ -171,15 +177,15 @@ export function ReloadSection() {
</div>
<Show when={setReloadSubmission.result && (setReloadSubmission.result as any).error}>
- {(err: any) => <div data-slot="form-error">{err()}</div>}
+ {(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="button" data-color="ghost" onClick={() => hide()}>
- Cancel
+ {i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={setReloadSubmission.pending}>
- {setReloadSubmission.pending ? "Saving..." : "Save"}
+ {setReloadSubmission.pending ? i18n.t("workspace.reload.saving") : i18n.t("workspace.reload.save")}
</button>
</div>
</form>
@@ -188,21 +194,21 @@ export function ReloadSection() {
<div data-slot="section-content">
<div data-slot="reload-error">
<p>
- Reload failed at{" "}
- {billingInfo()?.timeReloadError!.toLocaleString("en-US", {
+ {i18n.t("workspace.reload.failedAt")}{" "}
+ {billingInfo()?.timeReloadError!.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
})}
- . Reason: {billingInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try
- again.
+ . {i18n.t("workspace.reload.reason")} {billingInfo()?.reloadError?.replace(/\.$/, "")}.{" "}
+ {i18n.t("workspace.reload.updatePaymentMethod")}
</p>
<form action={reload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost" type="submit" disabled={reloadSubmission.pending}>
- {reloadSubmission.pending ? "Retrying..." : "Retry"}
+ {reloadSubmission.pending ? i18n.t("workspace.reload.retrying") : i18n.t("workspace.reload.retry")}
</button>
</form>
</div>
diff --git a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx
index 140e1e732..f26c7291d 100644
--- a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx
@@ -20,6 +20,7 @@ import {
Legend,
type ChartConfiguration,
} from "chart.js"
+import { useI18n } from "~/context/i18n"
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend)
@@ -90,10 +91,8 @@ async function getCosts(workspaceID: string, year: number, month: number) {
usage: usageData,
keys: keysData.map((key) => ({
id: key.keyId,
- displayName:
- key.timeDeleted !== null
- ? `${key.userEmail} - ${key.keyName} (deleted)`
- : `${key.userEmail} - ${key.keyName}`,
+ displayName: `${key.userEmail} - ${key.keyName}`,
+ deleted: key.timeDeleted !== null,
})),
}
}, workspaceID)
@@ -132,7 +131,7 @@ function formatDateLabel(dateStr: string): string {
date.setMonth(m - 1)
date.setDate(d)
date.setHours(0, 0, 0, 0)
- const month = date.toLocaleDateString("en-US", { month: "short" })
+ const month = date.toLocaleDateString(undefined, { month: "short" })
const day = date.getUTCDate().toString().padStart(2, "0")
return `${month} ${day}`
}
@@ -152,6 +151,7 @@ export function GraphSection() {
let canvasRef: HTMLCanvasElement | undefined
let chartInstance: Chart | undefined
const params = useParams()
+ const i18n = useI18n()
const now = new Date()
const [store, setStore] = createStore({
data: null as Awaited<ReturnType<typeof getCosts>> | null,
@@ -193,13 +193,14 @@ export function GraphSection() {
})
const getKeyName = (keyID: string | null): string => {
- if (!keyID || !store.data?.keys) return "All Keys"
+ if (!keyID || !store.data?.keys) return i18n.t("workspace.cost.allKeys")
const found = store.data.keys.find((k) => k.id === keyID)
- return found?.displayName ?? "All Keys"
+ if (!found) return i18n.t("workspace.cost.allKeys")
+ return found.deleted ? `${found.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : found.displayName
}
const formatMonthYear = () =>
- new Date(store.year, store.month, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" })
+ new Date(store.year, store.month, 1).toLocaleDateString(undefined, { month: "long", year: "numeric" })
const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
@@ -216,6 +217,7 @@ export function GraphSection() {
const colorText = styles.getPropertyValue("--color-text").trim()
const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
const colorBorder = styles.getPropertyValue("--color-border").trim()
+ const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})`
const dailyDataSub = new Map<string, Map<string, number>>()
const dailyDataNonSub = new Map<string, Map<string, number>>()
@@ -255,7 +257,7 @@ export function GraphSection() {
.map((model) => {
const color = getModelColor(model)
return {
- label: `${model} (sub)`,
+ label: `${model}${subSuffix}`,
data: dates.map((date) => (dailyDataSub.get(date)?.get(model) || 0) / 100_000_000),
backgroundColor: addOpacityToColor(color, 0.5),
hoverBackgroundColor: addOpacityToColor(color, 0.7),
@@ -344,8 +346,8 @@ export function GraphSection() {
chart.data.datasets?.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i)
const label = dataset.label || ""
- const isSub = label.endsWith(" (sub)")
- const model = isSub ? label.slice(0, -6) : label
+ const isSub = label.endsWith(subSuffix)
+ const model = isSub ? label.slice(0, -subSuffix.length) : label
const baseColor = getModelColor(model)
const originalColor = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
const color = i === legendItem.datasetIndex ? originalColor : addOpacityToColor(baseColor, 0.15)
@@ -360,8 +362,8 @@ export function GraphSection() {
chart.data.datasets?.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i)
const label = dataset.label || ""
- const isSub = label.endsWith(" (sub)")
- const model = isSub ? label.slice(0, -6) : label
+ const isSub = label.endsWith(subSuffix)
+ const model = isSub ? label.slice(0, -subSuffix.length) : label
const baseColor = getModelColor(model)
const color = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
meta.data.forEach((bar: any) => {
@@ -406,8 +408,8 @@ export function GraphSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
- <h2>Cost</h2>
- <p>Usage costs broken down by model.</p>
+ <h2>{i18n.t("workspace.cost.title")}</h2>
+ <p>{i18n.t("workspace.cost.subtitle")}</p>
</div>
<div data-slot="filter-container">
@@ -421,13 +423,13 @@ export function GraphSection() {
</button>
</div>
<Dropdown
- trigger={store.model === null ? "All Models" : store.model}
+ trigger={store.model === null ? i18n.t("workspace.cost.allModels") : store.model}
open={store.modelDropdownOpen}
onOpenChange={(open) => setStore({ modelDropdownOpen: open })}
>
<>
<button data-slot="model-item" onClick={() => onSelectModel(null)}>
- <span>All Models</span>
+ <span>{i18n.t("workspace.cost.allModels")}</span>
</button>
<For each={getModels()}>
{(model) => (
@@ -445,12 +447,14 @@ export function GraphSection() {
>
<>
<button data-slot="model-item" onClick={() => onSelectKey(null)}>
- <span>All Keys</span>
+ <span>{i18n.t("workspace.cost.allKeys")}</span>
</button>
<For each={store.data?.keys || []}>
{(key) => (
<button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
- <span>{key.displayName}</span>
+ <span>
+ {key.deleted ? `${key.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : key.displayName}
+ </span>
</button>
)}
</For>
@@ -462,7 +466,7 @@ export function GraphSection() {
when={chartConfig()}
fallback={
<div data-component="empty-state">
- <p>No usage data available for the selected period.</p>
+ <p>{i18n.t("workspace.cost.empty")}</p>
</div>
}
>
diff --git a/packages/console/app/src/routes/workspace/[id]/index.tsx b/packages/console/app/src/routes/workspace/[id]/index.tsx
index 2165ac98e..408f6a113 100644
--- a/packages/console/app/src/routes/workspace/[id]/index.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/index.tsx
@@ -8,9 +8,11 @@ import { ProviderSection } from "./provider-section"
import { GraphSection } from "./graph-section"
import { IconLogo } from "~/component/icon"
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
+import { useI18n } from "~/context/i18n"
export default function () {
const params = useParams()
+ const i18n = useI18n()
const userInfo = createAsync(() => querySessionInfo(params.id!))
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const checkoutAction = useAction(createCheckoutUrl)
@@ -35,9 +37,9 @@ export default function () {
<IconLogo />
<p>
<span>
- Reliable optimized models for coding agents.{" "}
+ {i18n.t("workspace.home.banner.beforeLink")}{" "}
<a target="_blank" href="/docs/zen">
- Learn more
+ {i18n.t("common.learnMore")}
</a>
.
</span>
@@ -52,12 +54,14 @@ export default function () {
disabled={checkoutSubmission.pending || store.checkoutRedirecting}
onClick={onClickCheckout}
>
- {checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable billing"}
+ {checkoutSubmission.pending || store.checkoutRedirecting
+ ? i18n.t("workspace.home.billing.loading")
+ : i18n.t("workspace.home.billing.enable")}
</button>
}
>
<span data-slot="balance">
- Current balance <b>${balance()}</b>
+ {i18n.t("workspace.home.billing.currentBalance")} <b>${balance()}</b>
</span>
</Show>
</span>
diff --git a/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx
index c55e0341a..837ab743a 100644
--- a/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx
@@ -7,22 +7,24 @@ import { createStore } from "solid-js/store"
import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./key-section.module.css"
import { Actor } from "@opencode-ai/console-core/actor.js"
+import { useI18n } from "~/context/i18n"
+import { formError, localizeError } from "~/lib/form-error"
const removeKey = action(async (form: FormData) => {
"use server"
const id = form.get("id")?.toString()
- if (!id) return { error: "ID is required" }
+ if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
+ if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
}, "key.remove")
const createKey = action(async (form: FormData) => {
"use server"
const name = form.get("name")?.toString().trim()
- if (!name) return { error: "Name is required" }
+ if (!name) return { error: formError.nameRequired }
const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
+ if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
@@ -45,6 +47,7 @@ const listKeys = query(async (workspaceID: string) => {
export function KeySection() {
const params = useParams()
+ const i18n = useI18n()
const keys = createAsync(() => listKeys(params.id!))
const submission = useSubmission(createKey)
const [store, setStore] = createStore({ show: false })
@@ -73,11 +76,11 @@ export function KeySection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
- <h2>API Keys</h2>
+ <h2>{i18n.t("workspace.keys.title")}</h2>
<div data-slot="title-row">
- <p>Manage your API keys for accessing opencode services.</p>
+ <p>{i18n.t("workspace.keys.subtitle")}</p>
<button data-color="primary" onClick={() => show()}>
- Create API Key
+ {i18n.t("workspace.keys.create")}
</button>
</div>
</div>
@@ -89,19 +92,19 @@ export function KeySection() {
data-component="input"
name="name"
type="text"
- placeholder="Enter key name"
+ placeholder={i18n.t("workspace.keys.placeholder")}
/>
<Show when={submission.result && submission.result.error}>
- {(err) => <div data-slot="form-error">{err()}</div>}
+ {(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
- Cancel
+ {i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
- {submission.pending ? "Creating..." : "Create"}
+ {submission.pending ? i18n.t("common.creating") : i18n.t("common.create")}
</button>
</div>
</form>
@@ -111,17 +114,17 @@ export function KeySection() {
when={keys()?.length}
fallback={
<div data-component="empty-state">
- <p>Create an opencode Gateway API key</p>
+ <p>{i18n.t("workspace.keys.empty")}</p>
</div>
}
>
<table data-slot="api-keys-table-element">
<thead>
<tr>
- <th>Name</th>
- <th>Key</th>
- <th>Created By</th>
- <th>Last Used</th>
+ <th>{i18n.t("workspace.keys.table.name")}</th>
+ <th>{i18n.t("workspace.keys.table.key")}</th>
+ <th>{i18n.t("workspace.keys.table.createdBy")}</th>
+ <th>{i18n.t("workspace.keys.table.lastUsed")}</th>
<th></th>
</tr>
</thead>
@@ -143,7 +146,7 @@ export function KeySection() {
setCopied(true)
setTimeout(() => setCopied(false), 1000)
}}
- title="Copy API key"
+ title={i18n.t("workspace.keys.copyApiKey")}
>
<span>{key.keyDisplay}</span>
<Show when={copied()} fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}>
@@ -160,7 +163,7 @@ export function KeySection() {
<form action={removeKey} method="post">
<input type="hidden" name="id" value={key.id} />
<input type="hidden" name="workspaceID" value={params.id} />
- <button data-color="ghost">Delete</button>
+ <button data-color="ghost">{i18n.t("workspace.keys.delete")}</button>
</form>
</td>
</tr>
diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx
index fd75bcec3..dce23a496 100644
--- a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx
@@ -7,6 +7,8 @@ import { UserRole } from "@opencode-ai/console-core/schema/user.sql.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { User } from "@opencode-ai/console-core/user.js"
import { RoleDropdown } from "./role-dropdown"
+import { useI18n } from "~/context/i18n"
+import { formError, localizeError } from "~/lib/form-error"
const listMembers = query(async (workspaceID: string) => {
"use server"
@@ -22,14 +24,14 @@ const listMembers = query(async (workspaceID: string) => {
const inviteMember = action(async (form: FormData) => {
"use server"
const email = form.get("email")?.toString().trim()
- if (!email) return { error: "Email is required" }
+ if (!email) return { error: formError.emailRequired }
const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
+ if (!workspaceID) return { error: formError.workspaceRequired }
const role = form.get("role")?.toString() as (typeof UserRole)[number]
- if (!role) return { error: "Role is required" }
+ if (!role) return { error: formError.roleRequired }
const limit = form.get("limit")?.toString()
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
- if (monthlyLimit !== null && monthlyLimit < 0) return { error: "Set a valid monthly limit" }
+ if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
return json(
await withActor(
() =>
@@ -45,9 +47,9 @@ const inviteMember = action(async (form: FormData) => {
const removeMember = action(async (form: FormData) => {
"use server"
const id = form.get("id")?.toString()
- if (!id) return { error: "ID is required" }
+ if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
+ if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
@@ -64,14 +66,14 @@ const updateMember = action(async (form: FormData) => {
"use server"
const id = form.get("id")?.toString()
- if (!id) return { error: "ID is required" }
+ if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
+ if (!workspaceID) return { error: formError.workspaceRequired }
const role = form.get("role")?.toString() as (typeof UserRole)[number]
- if (!role) return { error: "Role is required" }
+ if (!role) return { error: formError.roleRequired }
const limit = form.get("limit")?.toString()
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
- if (monthlyLimit !== null && monthlyLimit < 0) return { error: "Set a valid monthly limit" }
+ if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
return json(
await withActor(
@@ -85,7 +87,14 @@ const updateMember = action(async (form: FormData) => {
)
}, "member.update")
-function MemberRow(props: { member: any; workspaceID: string; actorID: string; actorRole: string }) {
+function MemberRow(props: {
+ member: any
+ workspaceID: string
+ actorID: string
+ actorRole: string
+ roleOptions: { value: string; label: string; description: string }[]
+}) {
+ const i18n = useI18n()
const submission = useSubmission(updateMember)
const isCurrentUser = () => props.actorID === props.member.id
const isAdmin = () => props.actorRole === "admin"
@@ -120,12 +129,12 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
const dateLastUsed = props.member.timeMonthlyUsageUpdated
if (!dateLastUsed) return 0
- const current = new Date().toLocaleDateString("en-US", {
+ const current = new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
timeZone: "UTC",
})
- const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
+ const lastUsed = dateLastUsed.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
timeZone: "UTC",
@@ -133,18 +142,22 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
return current === lastUsed ? (props.member.monthlyUsage ?? 0) : 0
})()
- const limit = props.member.monthlyLimit ? `$${props.member.monthlyLimit}` : "no limit"
+ const limit = props.member.monthlyLimit
+ ? `$${props.member.monthlyLimit}`
+ : i18n.t("workspace.members.noLimitLowercase")
return `$${(currentUsage / 100000000).toFixed(2)} / ${limit}`
}
+ const roleLabel = (value: string) => props.roleOptions.find((option) => option.value === value)?.label ?? value
+
return (
<tr>
<td data-slot="member-email">{props.member.authEmail ?? props.member.email}</td>
<td data-slot="member-role">
- <Show when={store.editing && !isCurrentUser()} fallback={<span>{props.member.role}</span>}>
+ <Show when={store.editing && !isCurrentUser()} fallback={<span>{roleLabel(props.member.role)}</span>}>
<RoleDropdown
value={store.selectedRole}
- options={roleOptions}
+ options={props.roleOptions}
onChange={(value) => setStore("selectedRole", value as (typeof UserRole)[number])}
/>
</Show>
@@ -156,12 +169,12 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
type="number"
value={store.limit}
onInput={(e) => setStore("limit", e.currentTarget.value)}
- placeholder="No limit"
+ placeholder={i18n.t("workspace.members.noLimit")}
min="0"
/>
</Show>
</td>
- <td data-slot="member-joined">{props.member.timeSeen ? "" : "invited"}</td>
+ <td data-slot="member-joined">{props.member.timeSeen ? "" : i18n.t("workspace.members.invited")}</td>
<Show when={isAdmin()}>
<td data-slot="member-actions">
<Show
@@ -169,13 +182,13 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
fallback={
<>
<button data-color="ghost" onClick={() => show()}>
- Edit
+ {i18n.t("workspace.members.edit")}
</button>
<Show when={!isCurrentUser()}>
<form action={removeMember} method="post">
<input type="hidden" name="id" value={props.member.id} />
<input type="hidden" name="workspaceID" value={props.workspaceID} />
- <button data-color="ghost">Delete</button>
+ <button data-color="ghost">{i18n.t("workspace.members.delete")}</button>
</form>
</Show>
</>
@@ -187,11 +200,11 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
<input type="hidden" name="role" value={store.selectedRole} />
<input type="hidden" name="limit" value={store.limit} />
<button type="submit" data-color="ghost" disabled={submission.pending}>
- {submission.pending ? "Saving..." : "Save"}
+ {submission.pending ? i18n.t("workspace.members.saving") : i18n.t("workspace.members.save")}
</button>
<Show when={!submission.pending}>
<button type="button" data-color="ghost" onClick={() => hide()}>
- Cancel
+ {i18n.t("common.cancel")}
</button>
</Show>
</form>
@@ -202,13 +215,9 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
)
}
-const roleOptions = [
- { value: "admin", description: "Can manage models, members, and billing" },
- { value: "member", description: "Can only generate API keys for themselves" },
-]
-
export function MemberSection() {
const params = useParams()
+ const i18n = useI18n()
const data = createAsync(() => listMembers(params.id!))
const submission = useSubmission(inviteMember)
const [store, setStore] = createStore({
@@ -219,6 +228,19 @@ export function MemberSection() {
let input: HTMLInputElement
+ const roleOptions = [
+ {
+ value: "admin",
+ label: i18n.t("workspace.members.role.admin"),
+ description: i18n.t("workspace.members.role.adminDescription"),
+ },
+ {
+ value: "member",
+ label: i18n.t("workspace.members.role.member"),
+ description: i18n.t("workspace.members.role.memberDescription"),
+ },
+ ]
+
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
setStore("show", false)
@@ -243,20 +265,20 @@ export function MemberSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
- <h2>Members</h2>
+ <h2>{i18n.t("workspace.members.title")}</h2>
<div data-slot="title-row">
- <p>Manage workspace members and their permissions.</p>
+ <p>{i18n.t("workspace.members.subtitle")}</p>
<Show when={data()?.actorRole === "admin"}>
<button data-color="primary" onClick={() => show()}>
- Invite Member
+ {i18n.t("workspace.members.invite")}
</button>
</Show>
</div>
</div>
<div data-slot="beta-notice">
- Workspaces are free for teams during the beta.{" "}
+ {i18n.t("workspace.members.beta.beforeLink")}{" "}
<a href="/docs/zen/#for-teams" target="_blank" rel="noopener noreferrer">
- Learn more
+ {i18n.t("common.learnMore")}
</a>
.
</div>
@@ -264,17 +286,17 @@ export function MemberSection() {
<form action={inviteMember} method="post" data-slot="create-form">
<div data-slot="input-row">
<div data-slot="input-field">
- <p>Invitee</p>
+ <p>{i18n.t("workspace.members.form.invitee")}</p>
<input
ref={(r) => (input = r)}
data-component="input"
name="email"
type="text"
- placeholder="Enter email"
+ placeholder={i18n.t("workspace.members.form.emailPlaceholder")}
/>
</div>
<div data-slot="input-field">
- <p>Role</p>
+ <p>{i18n.t("workspace.members.form.role")}</p>
<RoleDropdown
value={store.selectedRole}
options={roleOptions}
@@ -282,12 +304,12 @@ export function MemberSection() {
/>
</div>
<div data-slot="input-field">
- <p>Monthly spending limit</p>
+ <p>{i18n.t("workspace.members.form.monthlyLimit")}</p>
<input
data-component="input"
name="limit"
type="number"
- placeholder="No limit"
+ placeholder={i18n.t("workspace.members.noLimit")}
value={store.limit}
onInput={(e) => setStore("limit", e.currentTarget.value)}
min="0"
@@ -295,16 +317,16 @@ export function MemberSection() {
</div>
</div>
<Show when={submission.result && submission.result.error}>
- {(err) => <div data-slot="form-error">{err()}</div>}
+ {(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
<input type="hidden" name="role" value={store.selectedRole} />
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
- Cancel
+ {i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
- {submission.pending ? "Inviting..." : "Invite"}
+ {submission.pending ? i18n.t("workspace.members.inviting") : i18n.t("workspace.members.invite")}
</button>
</div>
</form>
@@ -313,9 +335,9 @@ export function MemberSection() {
<table data-slot="members-table-element">
<thead>
<tr>
- <th>Email</th>
- <th>Role</th>
- <th>Month limit</th>
+ <th>{i18n.t("workspace.members.table.email")}</th>
+ <th>{i18n.t("workspace.members.table.role")}</th>
+ <th>{i18n.t("workspace.members.table.monthLimit")}</th>
<th></th>
<Show when={data()?.actorRole === "admin"}>
<th></th>
@@ -331,6 +353,7 @@ export function MemberSection() {
workspaceID={params.id!}
actorID={data()!.actorID}
actorRole={data()!.actorRole}
+ roleOptions={roleOptions}
/>
)}
</For>
diff --git a/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.tsx b/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.tsx
index 93dfb4e4b..0dd254a37 100644
--- a/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.tsx
@@ -4,6 +4,7 @@ import "./role-dropdown.css"
interface RoleOption {
value: string
+ label: string
description: string
}
@@ -15,6 +16,7 @@ interface RoleDropdownProps {
export function RoleDropdown(props: RoleDropdownProps) {
const [open, setOpen] = createSignal(false)
+ const selected = () => props.options.find((option) => option.value === props.value)?.label ?? props.value
const handleSelect = (value: string) => {
props.onChange(value)
@@ -22,7 +24,7 @@ export function RoleDropdown(props: RoleDropdownProps) {
}
return (
- <Dropdown trigger={props.value} open={open()} onOpenChange={setOpen} class="role-dropdown">
+ <Dropdown trigger={selected()} open={open()} onOpenChange={setOpen} class="role-dropdown">
<>
{props.options.map((option) => (
<button
@@ -32,7 +34,7 @@ export function RoleDropdown(props: RoleDropdownProps) {
onClick={() => handleSelect(option.value)}
>
<div>
- <strong>{option.value}</strong>
+ <strong>{option.label}</strong>
<p>{option.description}</p>
</div>
</button>
diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.tsx b/packages/console/app/src/routes/workspace/[id]/model-section.tsx
index 38813d276..d67c72b56 100644
--- a/packages/console/app/src/routes/workspace/[id]/model-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/model-section.tsx
@@ -16,6 +16,8 @@ import {
IconXai,
IconZai,
} from "~/component/icon"
+import { useI18n } from "~/context/i18n"
+import { formError } from "~/lib/form-error"
const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic"
@@ -59,9 +61,9 @@ const getModelsInfo = query(async (workspaceID: string) => {
const updateModel = action(async (form: FormData) => {
"use server"
const model = form.get("model")?.toString()
- if (!model) return { error: "Model is required" }
+ if (!model) return { error: formError.modelRequired }
const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
+ if (!workspaceID) return { error: formError.workspaceRequired }
const enabled = form.get("enabled")?.toString() === "true"
return json(
withActor(async () => {
@@ -77,6 +79,7 @@ const updateModel = action(async (form: FormData) => {
export function ModelSection() {
const params = useParams()
+ const i18n = useI18n()
const modelsInfo = createAsync(() => getModelsInfo(params.id!))
const userInfo = createAsync(() => querySessionInfo(params.id!))
@@ -91,9 +94,10 @@ export function ModelSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
- <h2>Models</h2>
+ <h2>{i18n.t("workspace.models.title")}</h2>
<p>
- Manage which models workspace members can access. <a href="/docs/zen#pricing ">Learn more</a>.
+ {i18n.t("workspace.models.subtitle.beforeLink")} <a href="/docs/zen#pricing ">{i18n.t("common.learnMore")}</a>
+ .
</p>
</div>
<div data-slot="models-list">
@@ -102,9 +106,9 @@ export function ModelSection() {
<table data-slot="models-table-element">
<thead>
<tr>
- <th>Model</th>
+ <th>{i18n.t("workspace.models.table.model")}</th>
<th></th>
- <th>Enabled</th>
+ <th>{i18n.t("workspace.models.table.enabled")}</th>
</tr>
</thead>
<tbody>
diff --git a/packages/console/app/src/routes/workspace/[id]/new-user-section.tsx b/packages/console/app/src/routes/workspace/[id]/new-user-section.tsx
index 30763ea31..a50efc288 100644
--- a/packages/console/app/src/routes/workspace/[id]/new-user-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/new-user-section.tsx
@@ -5,6 +5,7 @@ import { Key } from "@opencode-ai/console-core/key.js"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import styles from "./new-user-section.module.css"
+import { useI18n } from "~/context/i18n"
const getUsageInfo = query(async (workspaceID: string) => {
"use server"
@@ -20,6 +21,7 @@ const listKeys = query(async (workspaceID: string) => {
export function NewUserSection() {
const params = useParams()
+ const i18n = useI18n()
const [copiedKey, setCopiedKey] = createSignal(false)
const keys = createAsync(() => listKeys(params.id!))
const usage = createAsync(() => getUsageInfo(params.id!))
@@ -42,16 +44,16 @@ export function NewUserSection() {
<div class={styles.root}>
<div data-component="feature-grid">
<div data-slot="feature">
- <h3>Tested & Verified Models</h3>
- <p>We've benchmarked and tested models specifically for coding agents to ensure the best performance.</p>
+ <h3>{i18n.t("workspace.newUser.feature.tested.title")}</h3>
+ <p>{i18n.t("workspace.newUser.feature.tested.body")}</p>
</div>
<div data-slot="feature">
- <h3>Highest Quality</h3>
- <p>Access models configured for optimal performance - no downgrades or routing to cheaper providers.</p>
+ <h3>{i18n.t("workspace.newUser.feature.quality.title")}</h3>
+ <p>{i18n.t("workspace.newUser.feature.quality.body")}</p>
</div>
<div data-slot="feature">
- <h3>No Lock-in</h3>
- <p>Use Zen with any coding agent, and continue using other providers with opencode whenever you want.</p>
+ <h3>{i18n.t("workspace.newUser.feature.lockin.title")}</h3>
+ <p>{i18n.t("workspace.newUser.feature.lockin.body")}</p>
</div>
</div>
@@ -68,17 +70,17 @@ export function NewUserSection() {
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
}}
- title="Copy API key"
+ title={i18n.t("workspace.newUser.copyApiKey")}
>
<Show
when={copiedKey()}
fallback={
<>
- <IconCopy style={{ width: "16px", height: "16px" }} /> Copy Key
+ <IconCopy style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.newUser.copyKey")}
</>
}
>
- <IconCheck style={{ width: "16px", height: "16px" }} /> Copied!
+ <IconCheck style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.newUser.copied")}
</Show>
</button>
</div>
@@ -88,13 +90,15 @@ export function NewUserSection() {
<div data-component="next-steps">
<ol>
- <li>Enable billing</li>
+ <li>{i18n.t("workspace.newUser.step.enableBilling")}</li>
<li>
- Run <code>opencode auth login</code> and select opencode
+ {i18n.t("workspace.newUser.step.login.before")} <code>opencode auth login</code>{" "}
+ {i18n.t("workspace.newUser.step.login.after")}
</li>
- <li>Paste your API key</li>
+ <li>{i18n.t("workspace.newUser.step.pasteKey")}</li>
<li>
- Start opencode and run <code>/models</code> to select a model
+ {i18n.t("workspace.newUser.step.models.before")} <code>/models</code>{" "}
+ {i18n.t("workspace.newUser.step.models.after")}
</li>
</ol>
</div>
diff --git a/packages/console/app/src/routes/workspace/[id]/provider-section.tsx b/packages/console/app/src/routes/workspace/[id]/provider-section.tsx
index dbb170b62..fe04bf7c0 100644
--- a/packages/console/app/src/routes/workspace/[id]/provider-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/provider-section.tsx
@@ -4,6 +4,8 @@ import { Provider } from "@opencode-ai/console-core/provider.js"
import { withActor } from "~/context/auth.withActor"
import { createStore } from "solid-js/store"
import styles from "./provider-section.module.css"
+import { useI18n } from "~/context/i18n"
+import { formError, localizeError } from "~/lib/form-error"
const PROVIDERS = [
{ name: "OpenAI", key: "openai", prefix: "sk-" },
@@ -20,9 +22,9 @@ function maskCredentials(credentials: string) {
const removeProvider = action(async (form: FormData) => {
"use server"
const provider = form.get("provider")?.toString()
- if (!provider) return { error: "Provider is required" }
+ if (!provider) return { error: formError.providerRequired }
const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
+ if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Provider.remove({ provider }), workspaceID), {
revalidate: listProviders.key,
})
@@ -32,10 +34,10 @@ const saveProvider = action(async (form: FormData) => {
"use server"
const provider = form.get("provider")?.toString()
const credentials = form.get("credentials")?.toString()
- if (!provider) return { error: "Provider is required" }
- if (!credentials) return { error: "API key is required" }
+ if (!provider) return { error: formError.providerRequired }
+ if (!credentials) return { error: formError.apiKeyRequired }
const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
+ if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
@@ -55,6 +57,7 @@ const listProviders = query(async (workspaceID: string) => {
function ProviderRow(props: { provider: Provider }) {
const params = useParams()
+ const i18n = useI18n()
const providers = createAsync(() => listProviders(params.id!))
const saveSubmission = useSubmission(saveProvider, ([fd]) => fd.get("provider")?.toString() === props.provider.key)
const removeSubmission = useSubmission(
@@ -100,13 +103,16 @@ function ProviderRow(props: { provider: Provider }) {
ref={(r) => (input = r)}
name="credentials"
type="text"
- placeholder={`Enter ${props.provider.name} API key (${props.provider.prefix}...)`}
+ placeholder={i18n.t("workspace.providers.placeholder", {
+ provider: props.provider.name,
+ prefix: props.provider.prefix,
+ })}
autocomplete="off"
data-form-type="other"
data-lpignore="true"
/>
<Show when={saveSubmission.result && saveSubmission.result.error}>
- {(err) => <div data-slot="form-error">{err()}</div>}
+ {(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</div>
<input type="hidden" name="provider" value={props.provider.key} />
@@ -122,19 +128,19 @@ function ProviderRow(props: { provider: Provider }) {
when={!!providerData()}
fallback={
<button data-color="ghost" onClick={() => show()}>
- Configure
+ {i18n.t("workspace.providers.configure")}
</button>
}
>
<div data-slot="configured-actions">
<button data-color="ghost" onClick={() => show()}>
- Edit
+ {i18n.t("workspace.providers.edit")}
</button>
<form action={removeProvider} method="post" data-slot="delete-form">
<input type="hidden" name="provider" value={props.provider.key} />
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost" type="submit" disabled={removeSubmission.pending}>
- Delete
+ {i18n.t("workspace.providers.delete")}
</button>
</form>
</div>
@@ -148,11 +154,11 @@ function ProviderRow(props: { provider: Provider }) {
disabled={saveSubmission.pending}
form={`provider-form-${props.provider.key}`}
>
- {saveSubmission.pending ? "Saving..." : "Save"}
+ {saveSubmission.pending ? i18n.t("workspace.providers.saving") : i18n.t("workspace.providers.save")}
</button>
<Show when={!saveSubmission.pending}>
<button type="reset" data-color="ghost" onClick={() => hide()}>
- Cancel
+ {i18n.t("common.cancel")}
</button>
</Show>
</div>
@@ -163,18 +169,20 @@ function ProviderRow(props: { provider: Provider }) {
}
export function ProviderSection() {
+ const i18n = useI18n()
+
return (
<section class={styles.root}>
<div data-slot="section-title">
- <h2>Bring Your Own Key</h2>
- <p>Configure your own API keys from AI providers.</p>
+ <h2>{i18n.t("workspace.providers.title")}</h2>
+ <p>{i18n.t("workspace.providers.subtitle")}</p>
</div>
<div data-slot="providers-table">
<table data-slot="providers-table-element">
<thead>
<tr>
- <th>Provider</th>
- <th>API Key</th>
+ <th>{i18n.t("workspace.providers.table.provider")}</th>
+ <th>{i18n.t("workspace.providers.table.apiKey")}</th>
<th></th>
</tr>
</thead>
diff --git a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx
index 92169efee..5e8331e26 100644
--- a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx
@@ -6,6 +6,8 @@ import { Workspace } from "@opencode-ai/console-core/workspace.js"
import styles from "./settings-section.module.css"
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
+import { useI18n } from "~/context/i18n"
+import { formError, localizeError } from "~/lib/form-error"
const getWorkspaceInfo = query(async (workspaceID: string) => {
"use server"
@@ -29,10 +31,10 @@ const getWorkspaceInfo = query(async (workspaceID: string) => {
const updateWorkspace = action(async (form: FormData) => {
"use server"
const name = form.get("name")?.toString().trim()
- if (!name) return { error: "Workspace name is required." }
- if (name.length > 255) return { error: "Name must be 255 characters or less." }
+ if (!name) return { error: formError.workspaceNameRequired }
+ if (name.length > 255) return { error: formError.nameTooLong }
const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required." }
+ if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
@@ -46,6 +48,7 @@ const updateWorkspace = action(async (form: FormData) => {
export function SettingsSection() {
const params = useParams()
+ const i18n = useI18n()
const workspaceInfo = createAsync(() => getWorkspaceInfo(params.id!))
const submission = useSubmission(updateWorkspace)
const [store, setStore] = createStore({ show: false })
@@ -74,12 +77,12 @@ export function SettingsSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
- <h2>Settings</h2>
- <p>Update your workspace name and preferences.</p>
+ <h2>{i18n.t("workspace.settings.title")}</h2>
+ <p>{i18n.t("workspace.settings.subtitle")}</p>
</div>
<div data-slot="section-content">
<div data-slot="setting">
- <p>Workspace name</p>
+ <p>{i18n.t("workspace.settings.workspaceName")}</p>
<Show
when={!store.show}
fallback={
@@ -91,19 +94,19 @@ export function SettingsSection() {
data-component="input"
name="name"
type="text"
- placeholder="Workspace name"
- value={workspaceInfo()?.name ?? "Default"}
+ placeholder={i18n.t("workspace.settings.workspaceName")}
+ value={workspaceInfo()?.name ?? i18n.t("workspace.settings.defaultName")}
/>
<input type="hidden" name="workspaceID" value={params.id} />
<button type="submit" data-color="primary" disabled={submission.pending}>
- {submission.pending ? "Updating..." : "Save"}
+ {submission.pending ? i18n.t("workspace.settings.updating") : i18n.t("workspace.settings.save")}
</button>
<button type="reset" data-color="ghost" onClick={() => hide()}>
- Cancel
+ {i18n.t("common.cancel")}
</button>
</div>
<Show when={submission.result && submission.result.error}>
- {(err) => <div data-slot="form-error">{err()}</div>}
+ {(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</form>
}
@@ -111,7 +114,7 @@ export function SettingsSection() {
<div data-slot="value-with-action">
<p data-slot="current-value">{workspaceInfo()?.name}</p>
<button data-color="primary" onClick={() => show()}>
- Edit
+ {i18n.t("workspace.settings.edit")}
</button>
</div>
</Show>
diff --git a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
index 3b02b1e7d..079a27c3c 100644
--- a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
@@ -6,6 +6,7 @@ import { withActor } from "~/context/auth.withActor"
import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
import styles from "./usage-section.module.css"
import { createStore } from "solid-js/store"
+import { useI18n } from "~/context/i18n"
const PAGE_SIZE = 50
@@ -20,6 +21,7 @@ const queryUsageInfo = query(getUsageInfo, "usage.list")
export function UsageSection() {
const params = useParams()
+ const i18n = useI18n()
const usage = createAsync(() => queryUsageInfo(params.id!, 0))
const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null)
@@ -72,26 +74,26 @@ export function UsageSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
- <h2>Usage History</h2>
- <p>Recent API usage and costs.</p>
+ <h2>{i18n.t("workspace.usage.title")}</h2>
+ <p>{i18n.t("workspace.usage.subtitle")}</p>
</div>
<div data-slot="usage-table">
<Show
when={hasResults()}
fallback={
<div data-component="empty-state">
- <p>Make your first API call to get started.</p>
+ <p>{i18n.t("workspace.usage.empty")}</p>
</div>
}
>
<table data-slot="usage-table-element">
<thead>
<tr>
- <th>Date</th>
- <th>Model</th>
- <th>Input</th>
- <th>Output</th>
- <th>Cost</th>
+ <th>{i18n.t("workspace.usage.table.date")}</th>
+ <th>{i18n.t("workspace.usage.table.model")}</th>
+ <th>{i18n.t("workspace.usage.table.input")}</th>
+ <th>{i18n.t("workspace.usage.table.output")}</th>
+ <th>{i18n.t("workspace.usage.table.cost")}</th>
</tr>
</thead>
<tbody>
@@ -126,16 +128,18 @@ export function UsageSection() {
<Show when={isInputOpen()}>
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
<div data-slot="breakdown-row">
- <span data-slot="breakdown-label">Input</span>
+ <span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.input")}</span>
<span data-slot="breakdown-value">{usage.inputTokens}</span>
</div>
<div data-slot="breakdown-row">
- <span data-slot="breakdown-label">Cache Read</span>
+ <span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.cacheRead")}</span>
<span data-slot="breakdown-value">{usage.cacheReadTokens ?? 0}</span>
</div>
<Show when={isClaude}>
<div data-slot="breakdown-row">
- <span data-slot="breakdown-label">Cache Write</span>
+ <span data-slot="breakdown-label">
+ {i18n.t("workspace.usage.breakdown.cacheWrite")}
+ </span>
<span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span>
</div>
</Show>
@@ -158,11 +162,11 @@ export function UsageSection() {
<Show when={isOutputOpen()}>
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
<div data-slot="breakdown-row">
- <span data-slot="breakdown-label">Output</span>
+ <span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.output")}</span>
<span data-slot="breakdown-value">{usage.outputTokens}</span>
</div>
<div data-slot="breakdown-row">
- <span data-slot="breakdown-label">Reasoning</span>
+ <span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.reasoning")}</span>
<span data-slot="breakdown-value">{usage.reasoningTokens ?? 0}</span>
</div>
</div>
@@ -174,7 +178,9 @@ export function UsageSection() {
when={usage.enrichment?.plan === "sub"}
fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}
>
- subscription (${((usage.cost ?? 0) / 100000000).toFixed(4)})
+ {i18n.t("workspace.usage.subscription", {
+ amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
+ })}
</Show>
</td>
</tr>
diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx
index 4892ab418..5cbd67183 100644
--- a/packages/console/app/src/routes/workspace/common.tsx
+++ b/packages/console/app/src/routes/workspace/common.tsx
@@ -30,7 +30,7 @@ export function formatDateUTC(date: Date) {
timeZoneName: "short",
timeZone: "UTC",
}
- return date.toLocaleDateString("en-US", options)
+ return date.toLocaleDateString(undefined, options)
}
export function formatBalance(amount: number) {
diff --git a/packages/console/app/src/routes/zen/index.css b/packages/console/app/src/routes/zen/index.css
index 5055bac2a..acdfbe85d 100644
--- a/packages/console/app/src/routes/zen/index.css
+++ b/packages/console/app/src/routes/zen/index.css
@@ -177,6 +177,7 @@ body {
border-radius: 4px;
font-weight: 500;
text-decoration: none;
+ white-space: nowrap;
@media (max-width: 55rem) {
display: none;
diff --git a/packages/console/app/src/routes/zen/index.tsx b/packages/console/app/src/routes/zen/index.tsx
index accc8d67c..50a818e0d 100644
--- a/packages/console/app/src/routes/zen/index.tsx
+++ b/packages/console/app/src/routes/zen/index.tsx
@@ -19,6 +19,7 @@ import { Footer } from "~/component/footer"
import { Header } from "~/component/header"
import { getLastSeenWorkspaceID } from "../workspace/common"
import { IconGemini, IconMiniMax, IconZai } from "~/component/icon"
+import { useI18n } from "~/context/i18n"
const checkLoggedIn = query(async () => {
"use server"
@@ -28,10 +29,11 @@ const checkLoggedIn = query(async () => {
export default function Home() {
const loggedin = createAsync(() => checkLoggedIn())
+ const i18n = useI18n()
return (
<main data-page="zen">
{/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/}
- <Title>OpenCode Zen | A curated set of reliable optimized models for coding agents</Title>
+ <Title>{i18n.t("zen.title")}</Title>
<Link rel="canonical" href={`${config.baseUrl}/zen`} />
<Meta property="og:image" content="/social-share-zen.png" />
<Meta name="twitter:image" content="/social-share-zen.png" />
@@ -43,14 +45,10 @@ export default function Home() {
<div data-component="content">
<section data-component="hero">
<div data-slot="hero-copy">
- <img data-slot="zen logo light" src={zenLogoLight} alt="zen logo light" />
- <img data-slot="zen logo dark" src={zenLogoDark} alt="zen logo dark" />
- <h1>Reliable optimized models for coding agents</h1>
- <p>
- Zen gives you access to a curated set of AI models that OpenCode has tested and benchmarked specifically
- for coding agents. No need to worry about inconsistent performance and quality, use validated models
- that work.
- </p>
+ <img data-slot="zen logo light" src={zenLogoLight} alt="" />
+ <img data-slot="zen logo dark" src={zenLogoDark} alt="" />
+ <h1>{i18n.t("zen.hero.title")}</h1>
+ <p>{i18n.t("zen.hero.body")}</p>
<div data-slot="model-logos">
<div>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -123,7 +121,7 @@ export default function Home() {
</div>
</div>
<a href="/auth">
- <span>Get started with Zen </span>
+ <span>{i18n.t("zen.cta.start")}</span>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.5 12L17 12M13 16.5L17.5 12L13 7.5"
@@ -136,66 +134,63 @@ export default function Home() {
</div>
<div data-slot="pricing-copy">
<p>
- <strong>Add $20 Pay as you go balance</strong> <span>(+$1.23 card processing fee)</span>
+ <strong>{i18n.t("zen.pricing.title")}</strong> <span>{i18n.t("zen.pricing.fee")}</span>
</p>
- <p>Use with any agent. Set monthly spend limits. Cancel any time.</p>
+ <p>{i18n.t("zen.pricing.body")}</p>
</div>
</section>
<section data-component="comparison">
<video src={compareVideo} autoplay playsinline loop muted preload="auto" poster={compareVideoPoster}>
- Your browser does not support the video tag.
+ {i18n.t("common.videoUnsupported")}
</video>
</section>
<section data-component="problem">
<div data-slot="section-title">
- <h3>What problem is Zen solving?</h3>
- <p>
- There are so many models available, but only a few work well with coding agents. Most providers
- configure them differently with varying results.
- </p>
+ <h3>{i18n.t("zen.problem.title")}</h3>
+ <p>{i18n.t("zen.problem.body")}</p>
</div>
- <p>We're fixing this for everyone, not just OpenCode users.</p>
+ <p>{i18n.t("zen.problem.subtitle")}</p>
<ul>
<li>
- <span>[*]</span> Testing select models and consulting their teams
+ <span>[*]</span> {i18n.t("zen.problem.item1")}
</li>
<li>
- <span>[*]</span> Working with providers to ensure they’re delivered properly
+ <span>[*]</span> {i18n.t("zen.problem.item2")}
</li>
<li>
- <span>[*]</span> Benchmarking all model-provider combinations we recommend
+ <span>[*]</span> {i18n.t("zen.problem.item3")}
</li>
</ul>
</section>
<section data-component="how">
<div data-slot="section-title">
- <h3>How Zen works</h3>
- <p>While we suggest you use Zen with OpenCode, you can use Zen with any agent.</p>
+ <h3>{i18n.t("zen.how.title")}</h3>
+ <p>{i18n.t("zen.how.body")}</p>
</div>
<ul>
<li>
<span>[1]</span>
<div>
- <strong>Sign up and add $20 balance</strong> - follow the{" "}
- <a href="/docs/zen/#how-it-works" title="setup instructions">
- setup instructions
+ <strong>{i18n.t("zen.how.step1.title")}</strong> - {i18n.t("zen.how.step1.beforeLink")}{" "}
+ <a href="/docs/zen/#how-it-works" title={i18n.t("zen.how.step1.link")}>
+ {i18n.t("zen.how.step1.link")}
</a>
</div>
</li>
<li>
<span>[2]</span>
<div>
- <strong>Use Zen with transparent pricing</strong> - <a href="/docs/zen/#pricing">pay per request</a>{" "}
- with zero markups
+ <strong>{i18n.t("zen.how.step2.title")}</strong> -{" "}
+ <a href="/docs/zen/#pricing">{i18n.t("zen.how.step2.link")}</a> {i18n.t("zen.how.step2.afterLink")}
</div>
</li>
<li>
<span>[3]</span>
<div>
- <strong>Auto-top up</strong> - when your balance reaches $5 we’ll automatically add $20
+ <strong>{i18n.t("zen.how.step3.title")}</strong> - {i18n.t("zen.how.step3.body")}
</div>
</li>
</ul>
@@ -203,12 +198,12 @@ export default function Home() {
<section data-component="privacy">
<div data-slot="privacy-title">
- <h3>Your privacy is important to us</h3>
+ <h3>{i18n.t("zen.privacy.title")}</h3>
<div>
<span>[*]</span>
<p>
- All Zen models are hosted in the US. Providers follow a zero-retention policy and do not use your data
- for model training, with the <a href="/docs/zen/#privacy">following exceptions</a>.
+ {i18n.t("zen.privacy.beforeExceptions")}{" "}
+ <a href="/docs/zen/#privacy">{i18n.t("zen.privacy.exceptionsLink")}</a>.
</p>
</div>
</div>
@@ -224,7 +219,8 @@ export default function Home() {
<span>ex-CEO, Terminal Products</span>
</div>
<div data-slot="quote">
- <span>@OpenCode</span> Zen has been life changing, it's truly a no-brainer.
+ <span>@OpenCode</span>
+ {" Zen has been life changing, it's truly a no-brainer."}
</div>
</div>
</a>
@@ -237,7 +233,9 @@ export default function Home() {
<span>ex-Founder, SEED, PM, Melt, Pop, Dapt, Cadmus, and ViewPoint</span>
</div>
<div data-slot="quote">
- 4 out of 5 people on our team love using <span>@OpenCode</span> Zen.
+ {"4 out of 5 people on our team love using "}
+ <span>@OpenCode</span>
+ {" Zen."}
</div>
</div>
</a>
@@ -250,7 +248,9 @@ export default function Home() {
<span>ex-Hero, AWS</span>
</div>
<div data-slot="quote">
- I can't recommend <span>@OpenCode</span> Zen enough. Seriously, it’s really good.
+ {"I can't recommend "}
+ <span>@OpenCode</span>
+ {" Zen enough. Seriously, it's really good."}
</div>
</div>
</a>
@@ -263,7 +263,9 @@ export default function Home() {
<span>ex-Head of Design, Laravel</span>
</div>
<div data-slot="quote">
- With <span>@OpenCode</span> Zen I know all the models are tested and perfect for coding agents.
+ {"With "}
+ <span>@OpenCode</span>
+ {" Zen I know all the models are tested and perfect for coding agents."}
</div>
</div>
</a>
@@ -282,54 +284,40 @@ export default function Home() {
<section data-component="faq">
<div data-slot="section-title">
- <h3>FAQ</h3>
+ <h3>{i18n.t("common.faq")}</h3>
</div>
<ul>
<li>
- <Faq question="What is OpenCode Zen?">
- Zen is a curated set of AI models tested and benchmarked for coding agents created by the team behind
- OpenCode.
- </Faq>
+ <Faq question={i18n.t("zen.faq.q1")}>{i18n.t("zen.faq.a1")}</Faq>
</li>
<li>
- <Faq question="What makes Zen more accurate?">
- Zen only provides models that have been specifically tested and benchmarked for coding agents. You
- wouldn’t use a butter knife to cut steak, don’t use poor models for coding.
- </Faq>
+ <Faq question={i18n.t("zen.faq.q2")}>{i18n.t("zen.faq.a2")}</Faq>
</li>
<li>
- <Faq question="Is Zen cheaper?">
- Zen is not for profit. Zen passes through the costs from the model providers to you. The higher Zen’s
- usage the more OpenCode can negotiate better rates and pass those to you.
- </Faq>
+ <Faq question={i18n.t("zen.faq.q3")}>{i18n.t("zen.faq.a3")}</Faq>
</li>
<li>
- <Faq question="How much does Zen cost?">
- Zen <a href="/docs/zen/#pricing">charges per request</a> with zero markups, so you pay exactly what
- the model provider charges. Your total cost depends on usage, and you can set monthly spend limits in
- your <a href="/auth">account</a>. To cover costs, OpenCode adds only a small payment processing fee of
- $1.23 per $20 balance top-up.
+ <Faq question={i18n.t("zen.faq.q4")}>
+ {i18n.t("zen.faq.a4.p1.beforePricing")}{" "}
+ <a href="/docs/zen/#pricing">{i18n.t("zen.faq.a4.p1.pricingLink")}</a>{" "}
+ {i18n.t("zen.faq.a4.p1.afterPricing")} {i18n.t("zen.faq.a4.p2.beforeAccount")}{" "}
+ <a href="/auth">{i18n.t("zen.faq.a4.p2.accountLink")}</a>. {i18n.t("zen.faq.a4.p3")}
</Faq>
</li>
<li>
- <Faq question="What about data and privacy?">
- All Zen models are hosted in the US. Providers follow a zero-retention policy and do not use your data
- for model training, with the <a href="/docs/zen/#privacy">following exceptions</a>.
+ <Faq question={i18n.t("zen.faq.q5")}>
+ {i18n.t("zen.faq.a5.beforeExceptions")}{" "}
+ <a href="/docs/zen/#privacy">{i18n.t("zen.faq.a5.exceptionsLink")}</a>.
</Faq>
</li>
<li>
- <Faq question="Can I set spend limits?">Yes, you can set monthly spending limits in your account.</Faq>
+ <Faq question={i18n.t("zen.faq.q6")}>{i18n.t("zen.faq.a6")}</Faq>
</li>
<li>
- <Faq question="Can I cancel?">
- Yes, you can disable billing at any time and use your remaining balance.
- </Faq>
+ <Faq question={i18n.t("zen.faq.q7")}>{i18n.t("zen.faq.a7")}</Faq>
</li>
<li>
- <Faq question="Can I use Zen with other coding agents?">
- While Zen works great with OpenCode, you can use Zen with any agent. Follow the setup instructions in
- your preferred coding agent.
- </Faq>
+ <Faq question={i18n.t("zen.faq.q8")}>{i18n.t("zen.faq.a8")}</Faq>
</li>
</ul>
</section>