From 4ceabdffa07b1af8d99eb73622a4d549d99ec6d2 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 18 Sep 2025 10:59:01 -0400 Subject: wip: zen --- packages/console/app/src/component/icon.tsx | 82 +++++++++ .../component/workspace/billing-section.module.css | 114 ++++++++++++ .../src/component/workspace/billing-section.tsx | 193 +++++++++++++++++++++ .../console/app/src/component/workspace/common.tsx | 25 +++ .../src/component/workspace/key-section.module.css | 172 ++++++++++++++++++ .../app/src/component/workspace/key-section.tsx | 182 +++++++++++++++++++ .../workspace/monthly-limit-section.module.css | 102 +++++++++++ .../component/workspace/monthly-limit-section.tsx | 139 +++++++++++++++ .../workspace/new-user-section.module.css | 163 +++++++++++++++++ .../src/component/workspace/new-user-section.tsx | 97 +++++++++++ .../component/workspace/payment-section.module.css | 72 ++++++++ .../src/component/workspace/payment-section.tsx | 113 ++++++++++++ .../component/workspace/usage-section.module.css | 88 ++++++++++ .../app/src/component/workspace/usage-section.tsx | 128 ++++++++++++++ 14 files changed, 1670 insertions(+) create mode 100644 packages/console/app/src/component/icon.tsx create mode 100644 packages/console/app/src/component/workspace/billing-section.module.css create mode 100644 packages/console/app/src/component/workspace/billing-section.tsx create mode 100644 packages/console/app/src/component/workspace/common.tsx create mode 100644 packages/console/app/src/component/workspace/key-section.module.css create mode 100644 packages/console/app/src/component/workspace/key-section.tsx create mode 100644 packages/console/app/src/component/workspace/monthly-limit-section.module.css create mode 100644 packages/console/app/src/component/workspace/monthly-limit-section.tsx create mode 100644 packages/console/app/src/component/workspace/new-user-section.module.css create mode 100644 packages/console/app/src/component/workspace/new-user-section.tsx create mode 100644 packages/console/app/src/component/workspace/payment-section.module.css create mode 100644 packages/console/app/src/component/workspace/payment-section.tsx create mode 100644 packages/console/app/src/component/workspace/usage-section.module.css create mode 100644 packages/console/app/src/component/workspace/usage-section.tsx (limited to 'packages/console/app/src/component') diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx new file mode 100644 index 000000000..a82572e62 --- /dev/null +++ b/packages/console/app/src/component/icon.tsx @@ -0,0 +1,82 @@ +import { JSX } from "solid-js" + +export function IconLogo(props: JSX.SvgSVGAttributes) { + return ( + + + + + + + + + + + + + ) +} + +export function IconCopy(props: JSX.SvgSVGAttributes) { + return ( + + + + + ) +} + +export function IconCheck(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconCreditCard(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} diff --git a/packages/console/app/src/component/workspace/billing-section.module.css b/packages/console/app/src/component/workspace/billing-section.module.css new file mode 100644 index 000000000..0bb5709cb --- /dev/null +++ b/packages/console/app/src/component/workspace/billing-section.module.css @@ -0,0 +1,114 @@ +.root { + [data-slot="section-content"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + } + + [data-slot="reload-error"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + + p { + color: var(--color-danger); + font-size: var(--font-size-sm); + line-height: 1.4; + margin: 0; + flex: 1; + } + + [data-slot="create-form"] { + display: flex; + gap: var(--space-2); + margin: 0; + flex-shrink: 0; + } + } + [data-slot="payment"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + min-width: 14.5rem; + width: fit-content; + + @media (max-width: 30rem) { + width: 100%; + } + + [data-slot="credit-card"] { + padding: var(--space-3-5) var(--space-4); + background-color: var(--color-bg-surface); + border-radius: var(--border-radius-sm); + display: flex; + align-items: center; + justify-content: space-between; + + [data-slot="card-icon"] { + display: flex; + align-items: center; + color: var(--color-text-muted); + } + + [data-slot="card-details"] { + display: flex; + align-items: baseline; + gap: var(--space-1); + + [data-slot="secret"] { + position: relative; + bottom: 2px; + font-size: var(--font-size-lg); + color: var(--color-text-muted); + font-weight: 400; + } + + [data-slot="number"] { + font-size: var(--font-size-3xl); + font-weight: 500; + color: var(--color-text); + } + } + } + + [data-slot="button-row"] { + display: flex; + gap: var(--space-2); + align-items: center; + + @media (max-width: 30rem) { + flex-direction: column; + + > button { + width: 100%; + } + } + + [data-slot="create-form"] { + margin: 0; + } + + /* Make Enable Billing button full width when it's the only button */ + > button { + flex: 1; + } + } + } + [data-slot="usage"] { + p { + font-size: var(--font-size-sm); + line-height: 1.5; + color: var(--color-text-secondary); + b { + font-weight: 600; + } + } + } +} diff --git a/packages/console/app/src/component/workspace/billing-section.tsx b/packages/console/app/src/component/workspace/billing-section.tsx new file mode 100644 index 000000000..57316e208 --- /dev/null +++ b/packages/console/app/src/component/workspace/billing-section.tsx @@ -0,0 +1,193 @@ +import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router" +import { createMemo, Show } from "solid-js" +import { Billing } from "@opencode/console-core/billing.js" +import { withActor } from "~/context/auth.withActor" +import { IconCreditCard } from "~/component/icon" +import styles from "./billing-section.module.css" + +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() + // ORIGINAL CODE - COMMENTED OUT FOR TESTING + 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) + + // DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW + + // Scenario 1: User has not added billing details and has no balance + // const balanceInfo = () => ({ + // balance: 0, + // paymentMethodLast4: null as string | null, + // reload: false, + // reloadError: null as string | null, + // timeReloadError: null as Date | null, + // }) + + // Scenario 2: User has not added billing details but has a balance + // const balanceInfo = () => ({ + // balance: 1500000000, // $15.00 + // paymentMethodLast4: null as string | null, + // reload: false, + // reloadError: null as string | null, + // timeReloadError: null as Date | null + // }) + + // Scenario 3: User has added billing details (reload enabled) + // const balanceInfo = () => ({ + // balance: 750000000, // $7.50 + // paymentMethodLast4: "4242", + // reload: true, + // reloadError: null as string | null, + // timeReloadError: null as Date | null + // }) + + // Scenario 4: User has billing details but reload failed + // const balanceInfo = () => ({ + // balance: 250000000, // $2.50 + // paymentMethodLast4: "4242", + // reload: true, + // reloadError: "Your card was declined." as string, + // timeReloadError: new Date(Date.now() - 3600000) as Date // 1 hour ago + // }) + + 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/packages/console/app/src/component/workspace/common.tsx b/packages/console/app/src/component/workspace/common.tsx new file mode 100644 index 000000000..f85fd8423 --- /dev/null +++ b/packages/console/app/src/component/workspace/common.tsx @@ -0,0 +1,25 @@ +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/packages/console/app/src/component/workspace/key-section.module.css b/packages/console/app/src/component/workspace/key-section.module.css new file mode 100644 index 000000000..6a1d0c85f --- /dev/null +++ b/packages/console/app/src/component/workspace/key-section.module.css @@ -0,0 +1,172 @@ +.root { + [data-component="empty-state"] { + padding: var(--space-20) var(--space-6); + text-align: center; + border: 1px dashed var(--color-border); + border-radius: var(--border-radius-sm); + display: flex; + flex-direction: column; + gap: var(--space-2); + + p { + line-height: 1.5; + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + } + + [data-slot="create-form"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + + [data-slot="input-container"] { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + @media (max-width: 30rem) { + gap: var(--space-2); + } + + input { + flex: 1; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + font-family: var(--font-mono); + + &:focus { + outline: none; + border-color: var(--color-accent); + } + + &::placeholder { + color: var(--color-text-disabled); + } + } + + [data-slot="form-actions"] { + display: flex; + gap: var(--space-2); + } + + [data-slot="form-error"] { + color: var(--color-danger); + font-size: var(--font-size-sm); + margin-top: var(--space-1); + line-height: 1.4; + } + } + + [data-slot="api-keys-table"] { + overflow-x: auto; + } + + [data-slot="api-keys-table-element"] { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); + + thead { + border-bottom: 1px solid var(--color-border); + } + + th { + padding: var(--space-3) var(--space-4); + text-align: left; + font-weight: normal; + color: var(--color-text-muted); + text-transform: uppercase; + } + + td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-muted); + color: var(--color-text-muted); + font-family: var(--font-mono); + + &[data-slot="key-name"] { + color: var(--color-text); + font-family: var(--font-sans); + font-weight: 500; + } + + &[data-slot="key-value"] { + font-family: var(--font-mono); + + button { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-sm); + font-weight: 400; + border: none; + background-color: transparent; + color: var(--color-text-muted); + font-family: var(--font-mono); + border-radius: var(--border-radius-sm); + cursor: pointer; + transition: all 0.15s ease; + text-transform: none; + + &:hover:not(:disabled) { + background-color: var(--color-bg-surface); + color: var(--color-text); + } + + &:disabled { + cursor: default; + color: var(--color-text); + } + + span { + font-family: inherit; + } + } + } + + &[data-slot="key-date"] { + color: var(--color-text); + } + + &[data-slot="key-actions"] { + font-family: var(--font-sans); + } + } + + tbody tr { + &:last-child td { + border-bottom: none; + } + } + + @media (max-width: 40rem) { + th, + td { + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-xs); + } + + th { + &:nth-child(3) /* Date */ { + display: none; + } + } + + td { + &:nth-child(3) /* Date */ { + display: none; + } + } + } + } +} diff --git a/packages/console/app/src/component/workspace/key-section.tsx b/packages/console/app/src/component/workspace/key-section.tsx new file mode 100644 index 000000000..a2bd380ea --- /dev/null +++ b/packages/console/app/src/component/workspace/key-section.tsx @@ -0,0 +1,182 @@ +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/console-core/key.js" +import { withActor } from "~/context/auth.withActor" +import { createStore } from "solid-js/store" +import { formatDateUTC, formatDateForTable } from "./common" +import styles from "./key-section.module.css" + +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/packages/console/app/src/component/workspace/monthly-limit-section.module.css b/packages/console/app/src/component/workspace/monthly-limit-section.module.css new file mode 100644 index 000000000..02de058e4 --- /dev/null +++ b/packages/console/app/src/component/workspace/monthly-limit-section.module.css @@ -0,0 +1,102 @@ +.root { + [data-slot="section-content"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + } + + [data-slot="balance"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + min-width: 15rem; + width: fit-content; + + @media (max-width: 30rem) { + width: 100%; + } + + [data-slot="amount"] { + padding: var(--space-3-5) var(--space-4); + background-color: var(--color-bg-surface); + border-radius: var(--border-radius-sm); + display: flex; + align-items: baseline; + gap: var(--space-1); + justify-content: flex-end; + + [data-slot="currency"] { + position: relative; + bottom: 2px; + font-size: var(--font-size-lg); + color: var(--color-text-muted); + font-weight: 400; + } + + [data-slot="value"] { + font-size: var(--font-size-3xl); + font-weight: 500; + color: var(--color-text); + } + } + + [data-slot="create-form"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-top: var(--space-1); + + [data-slot="input-container"] { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + @media (max-width: 30rem) { + gap: var(--space-2); + } + + input { + flex: 1; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + font-family: var(--font-mono); + + &:focus { + outline: none; + border-color: var(--color-accent); + } + + &::placeholder { + color: var(--color-text-disabled); + } + } + + [data-slot="form-actions"] { + display: flex; + gap: var(--space-2); + justify-content: flex-end; + } + + [data-slot="form-error"] { + color: var(--color-danger); + font-size: var(--font-size-sm); + line-height: 1.4; + } + } + } + + [data-slot="usage-status"] { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin: 0; + line-height: 1.4; + } +} diff --git a/packages/console/app/src/component/workspace/monthly-limit-section.tsx b/packages/console/app/src/component/workspace/monthly-limit-section.tsx new file mode 100644 index 000000000..35da774d0 --- /dev/null +++ b/packages/console/app/src/component/workspace/monthly-limit-section.tsx @@ -0,0 +1,139 @@ +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/console-core/billing.js" +import styles from "./monthly-limit-section.module.css" + +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 numericLimit = parseInt(limit) + if (numericLimit < 0) return { error: "Set a valid monthly limit." } + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required." } + return json( + await withActor( + () => + Billing.setMonthlyLimit(numericLimit) + .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/packages/console/app/src/component/workspace/new-user-section.module.css b/packages/console/app/src/component/workspace/new-user-section.module.css new file mode 100644 index 000000000..2edc7cc14 --- /dev/null +++ b/packages/console/app/src/component/workspace/new-user-section.module.css @@ -0,0 +1,163 @@ +.root { + display: flex; + flex-direction: column; + gap: var(--space-8); + padding: var(--space-6); + background-color: var(--color-bg-surface); + border: 1px dashed var(--color-border); + border-radius: var(--border-radius-sm); + + @media (max-width: 30rem) { + gap: var(--space-8); + padding: var(--space-4); + } + + [data-component="feature-grid"] { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--space-6); + + @media (max-width: 30rem) { + grid-template-columns: 1fr; + gap: var(--space-4); + } + + [data-slot="feature"] { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + + h3 { + font-size: var(--font-size-sm); + font-weight: 600; + margin: 0; + color: var(--color-text); + text-transform: uppercase; + letter-spacing: -0.025rem; + } + + p { + font-size: var(--font-size-sm); + line-height: 1.5; + margin: 0; + color: var(--color-text-muted); + } + } + } + + [data-component="api-key-highlight"] { + display: flex; + flex-direction: column; + gap: var(--space-6); + + [data-slot="section-title"] { + display: flex; + flex-direction: column; + gap: var(--space-1); + + h2 { + font-size: var(--font-size-md); + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.03125rem; + margin: 0; + color: var(--color-text-secondary); + text-transform: uppercase; + + @media (max-width: 30rem) { + font-size: var(--font-size-md); + } + } + } + + [data-slot="key-display"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + + [data-slot="key-container"] { + display: flex; + gap: var(--space-3); + padding: var(--space-4); + border: 2px solid var(--color-accent); + border-radius: var(--border-radius-sm); + align-items: center; + + @media (max-width: 40rem) { + flex-direction: column; + gap: var(--space-3); + align-items: stretch; + } + + [data-slot="key-value"] { + flex: 1; + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--color-text); + background-color: var(--color-bg); + padding: var(--space-3); + border-radius: var(--border-radius-sm); + border: 1px solid var(--color-border); + word-break: break-all; + line-height: 1.4; + + @media (max-width: 40rem) { + font-size: var(--font-size-xs); + padding: var(--space-2-5); + } + } + + button { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + font-size: var(--font-size-sm); + font-weight: 500; + white-space: nowrap; + min-width: 130px; + + @media (max-width: 40rem) { + justify-content: center; + padding: var(--space-2-5) var(--space-3); + font-size: var(--font-size-xs); + min-width: 96px; + } + } + } + } + } + + [data-component="next-steps"] { + display: flex; + flex-direction: column; + gap: var(--space-6); + + ol { + margin: 0; + padding-left: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); + list-style-position: inside; + + li { + font-size: var(--font-size-md); + line-height: 1.5; + color: var(--color-text-secondary); + + code { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + padding: var(--space-1) var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + color: var(--color-text); + } + } + } + } +} diff --git a/packages/console/app/src/component/workspace/new-user-section.tsx b/packages/console/app/src/component/workspace/new-user-section.tsx new file mode 100644 index 000000000..5909072dd --- /dev/null +++ b/packages/console/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/console-core/key.js" +import { Billing } from "@opencode/console-core/billing.js" +import { withActor } from "~/context/auth.withActor" +import styles from "./new-user-section.module.css" + +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/packages/console/app/src/component/workspace/payment-section.module.css b/packages/console/app/src/component/workspace/payment-section.module.css new file mode 100644 index 000000000..ea8e2ed42 --- /dev/null +++ b/packages/console/app/src/component/workspace/payment-section.module.css @@ -0,0 +1,72 @@ +.root { + [data-slot="payments-table"] { + overflow-x: auto; + } + + [data-slot="payments-table-element"] { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); + + thead { + border-bottom: 1px solid var(--color-border); + } + + th { + padding: var(--space-3) var(--space-4); + text-align: left; + font-weight: normal; + color: var(--color-text-muted); + text-transform: uppercase; + } + + td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-muted); + color: var(--color-text-muted); + font-family: var(--font-mono); + + &[data-slot="payment-date"] { + color: var(--color-text); + } + + &[data-slot="payment-id"] { + font-family: var(--font-mono); + font-weight: 400; + color: var(--color-text-muted); + max-width: 200px; + word-break: break-word; + } + + &[data-slot="payment-amount"] { + color: var(--color-text); + } + } + + tbody tr { + &:last-child td { + border-bottom: none; + } + } + + @media (max-width: 40rem) { + th, + td { + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-xs); + } + + th { + &:nth-child(2) /* Payment ID */ { + display: none; + } + } + + td { + &:nth-child(2) /* Payment ID */ { + display: none; + } + } + } + } +} diff --git a/packages/console/app/src/component/workspace/payment-section.tsx b/packages/console/app/src/component/workspace/payment-section.tsx new file mode 100644 index 000000000..7be51a581 --- /dev/null +++ b/packages/console/app/src/component/workspace/payment-section.tsx @@ -0,0 +1,113 @@ +import { Billing } from "@opencode/console-core/billing.js" +import { query, action, useParams, createAsync, useAction } from "@solidjs/router" +import { For } from "solid-js" +import { withActor } from "~/context/auth.withActor" +import { formatDateUTC, formatDateForTable } from "./common" +import styles from "./payment-section.module.css" + +const getPaymentsInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return await Billing.payments() + }, workspaceID) +}, "payment.list") + +const downloadReceipt = action(async (workspaceID: string, paymentID: string) => { + "use server" + return withActor(() => Billing.generateReceiptUrl({ paymentID }), workspaceID) +}, "receipt.download") + +export function PaymentSection() { + const params = useParams() + // ORIGINAL CODE - COMMENTED OUT FOR TESTING + const payments = createAsync(() => getPaymentsInfo(params.id)) + const downloadReceiptAction = useAction(downloadReceipt) + + // DUMMY DATA FOR TESTING + // const payments = () => [ + // { + // id: "pi_3QK1x2FT9vXn4A6r1234567890", + // paymentID: "pi_3QK1x2FT9vXn4A6r1234567890", + // timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // 1 day ago + // amount: 2100000000, // $21.00 ($20 + $1 fee) + // }, + // { + // id: "pi_3QJ8k7FT9vXn4A6r0987654321", + // paymentID: "pi_3QJ8k7FT9vXn4A6r0987654321", + // timeCreated: new Date(Date.now() - 86400000 * 15).toISOString(), // 15 days ago + // amount: 2100000000, // $21.00 + // }, + // { + // id: "pi_3QI5m1FT9vXn4A6r5678901234", + // paymentID: "pi_3QI5m1FT9vXn4A6r5678901234", + // timeCreated: new Date(Date.now() - 86400000 * 32).toISOString(), // 32 days ago + // amount: 2100000000, // $21.00 + // }, + // { + // id: "pi_3QH2n9FT9vXn4A6r3456789012", + // paymentID: "pi_3QH2n9FT9vXn4A6r3456789012", + // timeCreated: new Date(Date.now() - 86400000 * 47).toISOString(), // 47 days ago + // amount: 2100000000, // $21.00 + // }, + // { + // id: "pi_3QG7p4FT9vXn4A6r7890123456", + // paymentID: "pi_3QG7p4FT9vXn4A6r7890123456", + // timeCreated: new Date(Date.now() - 86400000 * 63).toISOString(), // 63 days ago + // amount: 2100000000, // $21.00 + // }, + // ] + + return ( + payments() && + payments()!.length > 0 && ( +
+
+

Payments History

+

Recent payment transactions.

+
+
+ + + + + + + + + + + + {(payment) => { + const date = new Date(payment.timeCreated) + return ( + + + + + + + ) + }} + + +
DatePayment IDAmountReceipt
+ {formatDateForTable(date)} + {payment.id}${((payment.amount ?? 0) / 100000000).toFixed(2)} + +
+
+
+ ) + ) +} diff --git a/packages/console/app/src/component/workspace/usage-section.module.css b/packages/console/app/src/component/workspace/usage-section.module.css new file mode 100644 index 000000000..1a772ba87 --- /dev/null +++ b/packages/console/app/src/component/workspace/usage-section.module.css @@ -0,0 +1,88 @@ +.root { + [data-component="empty-state"] { + padding: var(--space-20) var(--space-6); + text-align: center; + border: 1px dashed var(--color-border); + border-radius: var(--border-radius-sm); + display: flex; + flex-direction: column; + gap: var(--space-2); + + p { + line-height: 1.5; + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + } + + [data-slot="usage-table"] { + overflow-x: auto; + } + + [data-slot="usage-table-element"] { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); + + thead { + border-bottom: 1px solid var(--color-border); + } + + th { + padding: var(--space-3) var(--space-4); + text-align: left; + font-weight: normal; + color: var(--color-text-muted); + text-transform: uppercase; + } + + td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-muted); + color: var(--color-text-muted); + font-family: var(--font-mono); + + &[data-slot="usage-date"] { + color: var(--color-text); + } + + &[data-slot="usage-model"] { + font-family: var(--font-sans); + font-weight: 400; + color: var(--color-text-secondary); + max-width: 200px; + word-break: break-word; + } + + &[data-slot="usage-cost"] { + color: var(--color-text); + } + } + + tbody tr { + &:last-child td { + border-bottom: none; + } + } + + @media (max-width: 40rem) { + th, + td { + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-xs); + } + + th { + &:nth-child(2) /* Model */ { + display: none; + } + } + + td { + &:nth-child(2) /* Model */ { + display: none; + } + } + } + } +} diff --git a/packages/console/app/src/component/workspace/usage-section.tsx b/packages/console/app/src/component/workspace/usage-section.tsx new file mode 100644 index 000000000..e68670c6d --- /dev/null +++ b/packages/console/app/src/component/workspace/usage-section.tsx @@ -0,0 +1,128 @@ +import { Billing } from "@opencode/console-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" +import styles from "./usage-section.module.css" + +const getUsageInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return await Billing.usages() + }, workspaceID) +}, "usage.list") + +export function UsageSection() { + const params = useParams() + // ORIGINAL CODE - COMMENTED OUT FOR TESTING + const usage = createAsync(() => getUsageInfo(params.id)) + + // DUMMY DATA FOR TESTING + // const usage = () => [ + // { + // timeCreated: new Date(Date.now() - 86400000 * 0).toISOString(), // Today + // model: "claude-3-5-sonnet-20241022", + // inputTokens: 1247, + // outputTokens: 423, + // cost: 125400000, // $1.254 + // }, + // { + // timeCreated: new Date(Date.now() - 86400000 * 0.5).toISOString(), // 12 hours ago + // model: "claude-3-haiku-20240307", + // inputTokens: 892, + // outputTokens: 156, + // cost: 23500000, // $0.235 + // }, + // { + // timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // Yesterday + // model: "claude-3-5-sonnet-20241022", + // inputTokens: 2134, + // outputTokens: 687, + // cost: 234700000, // $2.347 + // }, + // { + // timeCreated: new Date(Date.now() - 86400000 * 1.3).toISOString(), // 1.3 days ago + // model: "gpt-4o-mini", + // inputTokens: 567, + // outputTokens: 234, + // cost: 8900000, // $0.089 + // }, + // { + // timeCreated: new Date(Date.now() - 86400000 * 2).toISOString(), // 2 days ago + // model: "claude-3-opus-20240229", + // inputTokens: 1893, + // outputTokens: 945, + // cost: 445600000, // $4.456 + // }, + // { + // timeCreated: new Date(Date.now() - 86400000 * 2.7).toISOString(), // 2.7 days ago + // model: "gpt-4o", + // inputTokens: 1456, + // outputTokens: 532, + // cost: 156800000, // $1.568 + // }, + // { + // timeCreated: new Date(Date.now() - 86400000 * 3).toISOString(), // 3 days ago + // model: "claude-3-haiku-20240307", + // inputTokens: 634, + // outputTokens: 89, + // cost: 12300000, // $0.123 + // }, + // { + // timeCreated: new Date(Date.now() - 86400000 * 4).toISOString(), // 4 days ago + // model: "claude-3-5-sonnet-20241022", + // inputTokens: 3245, + // outputTokens: 1123, + // cost: 387200000, // $3.872 + // }, + // ] + + 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