From 4b1eca73eb64c62ebdf668eb18d587510066bd9d Mon Sep 17 00:00:00 2001 From: Jay V Date: Tue, 16 Sep 2025 16:16:30 -0400 Subject: ignore: zen --- .../src/component/workspace/billing-section.tsx | 153 +++++++++++++++++ cloud/app/src/component/workspace/common.tsx | 27 +++ cloud/app/src/component/workspace/key-section.tsx | 181 +++++++++++++++++++++ .../component/workspace/monthly-limit-section.tsx | 129 +++++++++++++++ .../src/component/workspace/new-user-section.tsx | 97 +++++++++++ .../src/component/workspace/payment-section.tsx | 56 +++++++ .../app/src/component/workspace/usage-section.tsx | 66 ++++++++ 7 files changed, 709 insertions(+) create mode 100644 cloud/app/src/component/workspace/billing-section.tsx create mode 100644 cloud/app/src/component/workspace/common.tsx create mode 100644 cloud/app/src/component/workspace/key-section.tsx create mode 100644 cloud/app/src/component/workspace/monthly-limit-section.tsx create mode 100644 cloud/app/src/component/workspace/new-user-section.tsx create mode 100644 cloud/app/src/component/workspace/payment-section.tsx create mode 100644 cloud/app/src/component/workspace/usage-section.tsx (limited to 'cloud/app/src/component') diff --git a/cloud/app/src/component/workspace/billing-section.tsx b/cloud/app/src/component/workspace/billing-section.tsx new file mode 100644 index 000000000..4bc4d4225 --- /dev/null +++ b/cloud/app/src/component/workspace/billing-section.tsx @@ -0,0 +1,153 @@ +import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router" +import { createEffect, createMemo, createSignal, For, Show } from "solid-js" +import { Billing } from "@opencode/cloud-core/billing.js" +import { withActor } from "~/context/auth.withActor" +import { IconCreditCard } from "~/component/icon" + +const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => { + "use server" + return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID) +}, "checkoutUrl") + +const reload = action(async (form: FormData) => { + "use server" + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: getBillingInfo.key }) +}, "billing.reload") + +const disableReload = action(async (form: FormData) => { + "use server" + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + return json(await withActor(() => Billing.disableReload(), workspaceID), { revalidate: getBillingInfo.key }) +}, "billing.disableReload") + +const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => { + "use server" + return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID) +}, "sessionUrl") + +const getBillingInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return await Billing.get() + }, workspaceID) +}, "billing.get") + +export function BillingSection() { + const params = useParams() + const balanceInfo = createAsync(() => getBillingInfo(params.id)) + const createCheckoutUrlAction = useAction(createCheckoutUrl) + const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl) + const createSessionUrlAction = useAction(createSessionUrl) + const createSessionUrlSubmission = useSubmission(createSessionUrl) + const disableReloadSubmission = useSubmission(disableReload) + const reloadSubmission = useSubmission(reload) + + const balanceAmount = createMemo(() => { + return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2) + }) + + return ( +
+
+

Billing

+

+ Manage payments methods. Contact us if you have any questions. +

+
+
+ +
+

+ Reload failed at{" "} + {balanceInfo()?.timeReloadError!.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + })} + . Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try + again. +

+
+ + +
+
+
+
+
+
+ +
+
+ ----}> + •••• + {balanceInfo()?.paymentMethodLast4} + +
+
+
+ { + const baseUrl = window.location.href + const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl) + if (checkoutUrl) { + window.location.href = checkoutUrl + } + }} + > + {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"} + + } + > + +
+ + +
+
+
+
+
+ +

+ You have ${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()} remaining in + your account. You can continue using the API with your remaining balance. +

+
+ +

+ Your current balance is ${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()} + . We'll automatically reload $20 (+$1.23 processing fee) when it reaches $5. +

+
+
+
+
+ ) +} diff --git a/cloud/app/src/component/workspace/common.tsx b/cloud/app/src/component/workspace/common.tsx new file mode 100644 index 000000000..fd1b8b1b6 --- /dev/null +++ b/cloud/app/src/component/workspace/common.tsx @@ -0,0 +1,27 @@ +export function formatDateForTable(date: Date) { + const options: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "short", + hour: "numeric", + minute: "2-digit", + hour12: true, + } + return date.toLocaleDateString("en-GB", options).replace(",", ",") +} + +export function formatDateUTC(date: Date) { + const options: Intl.DateTimeFormatOptions = { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short", + timeZone: "UTC", + } + return date.toLocaleDateString("en-US", options) +} + + diff --git a/cloud/app/src/component/workspace/key-section.tsx b/cloud/app/src/component/workspace/key-section.tsx new file mode 100644 index 000000000..ef1601567 --- /dev/null +++ b/cloud/app/src/component/workspace/key-section.tsx @@ -0,0 +1,181 @@ +import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router" +import { createEffect, createSignal, For, Show } from "solid-js" +import { IconCopy, IconCheck } from "~/component/icon" +import { Key } from "@opencode/cloud-core/key.js" +import { withActor } from "~/context/auth.withActor" +import { createStore } from "solid-js/store" +import { formatDateUTC, formatDateForTable } from "./common" + +const removeKey = action(async (form: FormData) => { + "use server" + const id = form.get("id")?.toString() + if (!id) return { error: "ID is required" } + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + 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" } + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + return json( + await withActor( + () => + Key.create({ name }) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ error: e.message as string })), + workspaceID, + ), + { revalidate: listKeys.key }, + ) +}, "key.create") + +const listKeys = query(async (workspaceID: string) => { + "use server" + return withActor(() => Key.list(), workspaceID) +}, "key.list") + +export function KeyCreateForm() { + const params = useParams() + const submission = useSubmission(createKey) + const [store, setStore] = createStore({ show: false }) + + let input: HTMLInputElement + + createEffect(() => { + if (!submission.pending && submission.result && !submission.result.error) { + hide() + } + }) + + function show() { + // submission.clear() does not clear the result in some cases, ie. + // 1. Create key with empty name => error shows + // 2. Put in a key name and creates the key => form hides + // 3. Click add key button again => form shows with the same error if + // submission.clear() is called only once + while (true) { + submission.clear() + if (!submission.result) break + } + setStore("show", true) + input.focus() + } + + function hide() { + setStore("show", false) + } + + return ( + show()}> + Create API Key + + } + > +
+
+ (input = r)} data-component="input" name="name" type="text" placeholder="Enter key name" /> + + {(err) =>
{err()}
} +
+
+ +
+ + +
+
+
+ ) +} + +export function KeySection() { + const params = useParams() + const keys = createAsync(() => listKeys(params.id)) + + function formatKey(key: string) { + if (key.length <= 11) return key + return `${key.slice(0, 7)}...${key.slice(-4)}` + } + + return ( +
+
+

API Keys

+

Manage your API keys for accessing opencode services.

+
+ +
+ +

Create an opencode Gateway API key

+
+ } + > + + + + + + + + + + + + {(key) => { + const [copied, setCopied] = createSignal(false) + // const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id) + return ( + + + + + + + ) + }} + + +
NameKeyCreated
{key.name} + + + {formatDateForTable(key.timeCreated)} + +
+ + + +
+
+ + +
+ ) +} diff --git a/cloud/app/src/component/workspace/monthly-limit-section.tsx b/cloud/app/src/component/workspace/monthly-limit-section.tsx new file mode 100644 index 000000000..0ed454789 --- /dev/null +++ b/cloud/app/src/component/workspace/monthly-limit-section.tsx @@ -0,0 +1,129 @@ +import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router" +import { createEffect, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { withActor } from "~/context/auth.withActor" +import { Billing } from "@opencode/cloud-core/billing.js" + +const getBillingInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return await Billing.get() + }, workspaceID) +}, "billing.get") + +const setMonthlyLimit = action(async (form: FormData) => { + "use server" + const limit = form.get("limit")?.toString() + if (!limit) return { error: "Limit is required" } + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + return json( + await withActor( + () => + Billing.setMonthlyLimit(parseInt(limit)) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ error: e.message as string })), + workspaceID, + ), + { revalidate: getBillingInfo.key }, + ) +}, "billing.setMonthlyLimit") + +export function MonthlyLimitSection() { + const params = useParams() + const submission = useSubmission(setMonthlyLimit) + const [store, setStore] = createStore({ show: false }) + const balanceInfo = createAsync(() => getBillingInfo(params.id)) + + let input: HTMLInputElement + + createEffect(() => { + if (!submission.pending && submission.result && !submission.result.error) { + hide() + } + }) + + function show() { + // submission.clear() does not clear the result in some cases, ie. + // 1. Create key with empty name => error shows + // 2. Put in a key name and creates the key => form hides + // 3. Click add key button again => form shows with the same error if + // submission.clear() is called only once + while (true) { + submission.clear() + if (!submission.result) break + } + setStore("show", true) + input.focus() + } + + function hide() { + setStore("show", false) + } + + return ( +
+
+

Monthly Limit

+

Set a monthly spending limit for your account.

+
+
+
+
+ {balanceInfo()?.monthlyLimit ? $ : null} + {balanceInfo()?.monthlyLimit ?? "-"} +
+ +
+ (input = r)} data-component="input" name="limit" type="number" placeholder="50" /> + + {(err) =>
{err()}
} +
+
+ +
+ + +
+ + } + > + +
+
+ No spending limit set.

}> +

+ Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $ + {(() => { + const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated + if (!dateLastUsed) return "0" + + const current = new Date().toLocaleDateString("en-US", { + year: "numeric", + month: "long", + timeZone: "UTC", + }) + const lastUsed = dateLastUsed.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + timeZone: "UTC", + }) + if (current !== lastUsed) return "0" + return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2) + })()} + . +

+
+
+
+ ) +} diff --git a/cloud/app/src/component/workspace/new-user-section.tsx b/cloud/app/src/component/workspace/new-user-section.tsx new file mode 100644 index 000000000..ca38390b3 --- /dev/null +++ b/cloud/app/src/component/workspace/new-user-section.tsx @@ -0,0 +1,97 @@ +import { query, useParams, createAsync } from "@solidjs/router" +import { createMemo, createSignal, Show } from "solid-js" +import { IconCopy, IconCheck } from "~/component/icon" +import { Key } from "@opencode/cloud-core/key.js" +import { Billing } from "@opencode/cloud-core/billing.js" +import { withActor } from "~/context/auth.withActor" + +const getUsageInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return await Billing.usages() + }, workspaceID) +}, "usage.list") + +const listKeys = query(async (workspaceID: string) => { + "use server" + return withActor(() => Key.list(), workspaceID) +}, "key.list") + +export function NewUserSection() { + const params = useParams() + const [copiedKey, setCopiedKey] = createSignal(false) + const keys = createAsync(() => listKeys(params.id)) + const usage = createAsync(() => getUsageInfo(params.id)) + const isNew = createMemo(() => { + const keysList = keys() + const usageList = usage() + return keysList?.length === 1 && (!usageList || usageList.length === 0) + }) + const defaultKey = createMemo(() => keys()?.at(-1)?.key) + + return ( + +
+
+
+

Tested & Verified Models

+

We've benchmarked and tested models specifically for coding agents to ensure the best performance.

+
+
+

Highest Quality

+

Access models configured for optimal performance - no downgrades or routing to cheaper providers.

+
+
+

No Lock-in

+

Use Zen with any coding agent, and continue using other providers with opencode whenever you want.

+
+
+ +
+ +
+
+ {defaultKey()} + +
+
+
+
+ +
+
    +
  1. Enable billing
  2. +
  3. + Run opencode auth login and select opencode +
  4. +
  5. Paste your API key
  6. +
  7. + Start opencode and run /models to select a model +
  8. +
+
+
+
+ ) +} + diff --git a/cloud/app/src/component/workspace/payment-section.tsx b/cloud/app/src/component/workspace/payment-section.tsx new file mode 100644 index 000000000..f802fa96a --- /dev/null +++ b/cloud/app/src/component/workspace/payment-section.tsx @@ -0,0 +1,56 @@ +import { Billing } from "@opencode/cloud-core/billing.js" +import { query, useParams, createAsync } from "@solidjs/router" +import { For } from "solid-js" +import { withActor } from "~/context/auth.withActor" +import { formatDateUTC, formatDateForTable } from "./common" + +const getPaymentsInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return await Billing.payments() + }, workspaceID) +}, "payment.list") + +export function PaymentSection() { + const params = useParams() + const payments = createAsync(() => getPaymentsInfo(params.id)) + + return ( + payments() && + payments()!.length > 0 && ( +
+
+

Payments History

+

Recent payment transactions.

+
+
+ + + + + + + + + + + {(payment) => { + const date = new Date(payment.timeCreated) + return ( + + + + + + ) + }} + + +
DatePayment IDAmount
+ {formatDateForTable(date)} + {payment.id}${((payment.amount ?? 0) / 100000000).toFixed(2)}
+
+
+ ) + ) +} diff --git a/cloud/app/src/component/workspace/usage-section.tsx b/cloud/app/src/component/workspace/usage-section.tsx new file mode 100644 index 000000000..a2ad507af --- /dev/null +++ b/cloud/app/src/component/workspace/usage-section.tsx @@ -0,0 +1,66 @@ +import { Billing } from "@opencode/cloud-core/billing.js" +import { query, useParams, createAsync } from "@solidjs/router" +import { createMemo, For, Show } from "solid-js" +import { formatDateUTC, formatDateForTable } from "./common" +import { withActor } from "~/context/auth.withActor" + +const getUsageInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return await Billing.usages() + }, workspaceID) +}, "usage.list") + +export function UsageSection() { + const params = useParams() + const usage = createAsync(() => getUsageInfo(params.id)) + + return ( +
+
+

Usage History

+

Recent API usage and costs.

+
+
+ 0} + fallback={ +
+

Make your first API call to get started.

+
+ } + > + + + + + + + + + + + + + {(usage) => { + const date = createMemo(() => new Date(usage.timeCreated)) + return ( + + + + + + + + ) + }} + + +
DateModelInputOutputCost
+ {formatDateForTable(date())} + {usage.model}{usage.inputTokens}{usage.outputTokens}${((usage.cost ?? 0) / 100000000).toFixed(4)}
+
+
+
+ ) +} -- cgit v1.2.3