feat(web): i18n (#12471)
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user