diff options
| author | Adam <[email protected]> | 2026-02-06 08:54:51 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-06 08:54:51 -0600 |
| commit | 812597bb8b101896a8988493d37261ff851ae502 (patch) | |
| tree | 3b713c13f49c65665e6a7296ed483a3f6f8befbf /packages/console/app/src/routes | |
| parent | 0ec5f6608bdfea5be62dbbdc4c04a61de6d3e67c (diff) | |
| download | opencode-812597bb8b101896a8988493d37261ff851ae502.tar.gz opencode-812597bb8b101896a8988493d37261ff851ae502.zip | |
feat(web): i18n (#12471)
Diffstat (limited to 'packages/console/app/src/routes')
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> |
