feat(web): i18n (#12471)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: 55rem) {
|
||||
display: none;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: 55rem) {
|
||||
display: none;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: 55rem) {
|
||||
display: none;
|
||||
|
||||
@@ -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 question={i18n.t("home.faq.q1")}>{i18n.t("home.faq.a1")}</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<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="How do I use OpenCode?">
|
||||
The easiest way to get started is to read the <a href="/docs">intro</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>
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: 55rem) {
|
||||
display: none;
|
||||
|
||||
@@ -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="jeff@amazon.com"
|
||||
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>
|
||||
|
||||
@@ -259,6 +259,7 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: 55rem) {
|
||||
display: none;
|
||||
|
||||
@@ -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 question={i18n.t("home.faq.q1")}>{i18n.t("home.faq.a1")}</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<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="How do I use OpenCode?">
|
||||
The easiest way to get started is to read the <a href="/docs">intro</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 question={i18n.t("home.faq.q6")}>{i18n.t("home.faq.a6")}</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<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="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>
|
||||
</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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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:contact@anoma.ly">Contact us</a> if you have any questions.
|
||||
{i18n.t("workspace.billing.subtitle.beforeLink")}{" "}
|
||||
<a href="mailto:contact@anoma.ly">{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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -177,6 +177,7 @@ body {
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: 55rem) {
|
||||
display: none;
|
||||
|
||||
@@ -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 question={i18n.t("zen.faq.q1")}>{i18n.t("zen.faq.a1")}</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<Faq question={i18n.t("zen.faq.q2")}>{i18n.t("zen.faq.a2")}</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<Faq question={i18n.t("zen.faq.q3")}>{i18n.t("zen.faq.a3")}</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<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 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 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="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.q6")}>{i18n.t("zen.faq.a6")}</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>
|
||||
<Faq question={i18n.t("zen.faq.q7")}>{i18n.t("zen.faq.a7")}</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>
|
||||
</li>
|
||||
<li>
|
||||
<Faq question="Can I set spend limits?">Yes, you can set monthly spending limits in your account.</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<Faq question="Can I cancel?">
|
||||
Yes, you can disable billing at any time and use your remaining balance.
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user