feat(web): i18n (#12471)

This commit is contained in:
Adam
2026-02-06 08:54:51 -06:00
committed by GitHub
parent 0ec5f6608b
commit 812597bb8b
75 changed files with 9868 additions and 726 deletions

View File

@@ -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" />

View File

@@ -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>
</>

View File

@@ -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>
</>

View File

@@ -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">