summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-10-31 12:22:17 -0400
committerFrank <[email protected]>2025-10-31 14:43:51 -0400
commit4bde3f7b156729733c0286a8119825d5782d8a31 (patch)
treec9e544fe866eb46c8046c5f7dd5b2292d77d7c28
parent4355027408d246c28aa08435fda16d385c595b49 (diff)
downloadopencode-4bde3f7b156729733c0286a8119825d5782d8a31.tar.gz
opencode-4bde3f7b156729733c0286a8119825d5782d8a31.zip
zen: billing page layout
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css111
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx228
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/index.tsx11
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css53
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx107
-rw-r--r--packages/console/app/src/routes/workspace/common.tsx16
6 files changed, 315 insertions, 211 deletions
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css
index b562dcd45..e0a80ef74 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css
@@ -4,9 +4,6 @@
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);
@@ -24,27 +21,65 @@
}
}
- [data-slot="payment"] {
+ [data-slot="section-content"] {
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;
+ }
+
+ [data-slot="balance-display"] {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
@media (max-width: 30rem) {
- width: 100%;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--space-2);
+ }
+
+ [data-slot="balance-amount"] {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ background-color: var(--color-bg-surface);
+ align-self: stretch;
+
+ [data-slot="balance-label"] {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ margin-top: var(--space-2);
+ font-weight: 400;
+ }
+
+ [data-slot="balance-value"] {
+ font-size: var(--font-size-2xl);
+ font-weight: 600;
+ color: var(--color-text);
+ }
+ }
+
+ [data-slot="balance-right-section"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ flex: 1;
}
[data-slot="credit-card"] {
- padding: var(--space-3-5) var(--space-4);
+ padding: var(--space-2) var(--space-4);
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
- justify-content: space-between;
+ gap: var(--space-3);
+ min-width: 150px;
+ align-self: flex-start;
[data-slot="card-icon"] {
display: flex;
@@ -56,19 +91,19 @@
display: flex;
align-items: baseline;
gap: var(--space-1);
+ flex: 1;
+ justify-content: flex-end;
[data-slot="secret"] {
- position: relative;
- bottom: 2px;
- font-size: var(--font-size-lg);
+ font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-weight: 400;
}
[data-slot="number"] {
- font-size: var(--font-size-3xl);
+ font-size: var(--font-size-sm);
font-weight: 500;
- color: var(--color-text);
+ color: var(--color-text-muted);
}
[data-slot="type"] {
@@ -77,41 +112,23 @@
color: var(--color-text-muted);
}
}
- }
-
- [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;
+ button {
+ white-space: nowrap;
+ flex-shrink: 0;
}
+ }
- /* Make Enable Billing button full width when it's the only button */
- >button {
- flex: 1;
- }
+ button {
+ align-self: flex-start;
+ white-space: nowrap;
+ flex-shrink: 0;
}
}
- [data-slot="usage"] {
- p {
- font-size: var(--font-size-sm);
- line-height: 1.5;
- color: var(--color-text-secondary);
-
- b {
- font-weight: 600;
- }
- }
+ [data-slot="enable-billing-button"] {
+ align-self: flex-start;
+ padding: var(--space-4);
+ min-width: 150px;
}
} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
index af4a47e48..7cbf29ad6 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
@@ -1,60 +1,24 @@
-import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
+import { action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
import { createMemo, Match, Show, Switch } from "solid-js"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import { IconCreditCard, IconStripe } from "~/component/icon"
import styles from "./billing-section.module.css"
-import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
-import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
-import { createCheckoutUrl } from "../../common"
-
-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 setReload = action(async (form: FormData) => {
- "use server"
- const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
- const reload = form.get("reload")?.toString() === "true"
- return json(
- await Database.use((tx) =>
- tx
- .update(BillingTable)
- .set({
- reload,
- })
- .where(eq(BillingTable.workspaceID, workspaceID)),
- ),
- { revalidate: getBillingInfo.key },
- )
-}, "billing.setReload")
+import { createCheckoutUrl, queryBillingInfo } from "../../common"
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 balanceInfo = createAsync(() => queryBillingInfo(params.id))
const createCheckoutUrlAction = useAction(createCheckoutUrl)
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
const createSessionUrlAction = useAction(createSessionUrl)
const createSessionUrlSubmission = useSubmission(createSessionUrl)
- const setReloadSubmission = useSubmission(setReload)
- const reloadSubmission = useSubmission(reload)
// DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
@@ -112,143 +76,95 @@ export function BillingSection() {
return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
})
- const hasBalance = createMemo(() => {
- return (balanceInfo()?.balance ?? 0) > 0 && balanceAmount() !== "0.00"
- })
-
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Billing</h2>
<p>
- Manage payments methods. <a href="mailto:[email protected]">Contact us</a> if you have any questions.
+ Manage payments methods. <a href="mailto:[email protected]">Contact us</a> if you have any
+ questions.
</p>
</div>
<div data-slot="section-content">
- <Show when={balanceInfo()?.reloadError}>
- <div data-slot="reload-error">
- <p>
- 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.
- </p>
- <form action={reload} method="post" data-slot="create-form">
- <input type="hidden" name="workspaceID" value={params.id} />
- <button data-color="primary" type="submit" disabled={reloadSubmission.pending}>
- {reloadSubmission.pending ? "Reloading..." : "Reload"}
- </button>
- </form>
- </div>
- </Show>
- <div data-slot="payment">
- <div data-slot="credit-card">
- <div data-slot="card-icon">
- <Switch fallback={<IconCreditCard style={{ width: "32px", height: "32px" }} />}>
- <Match when={balanceInfo()?.paymentMethodType === "link"}>
- <IconStripe style={{ width: "32px", height: "32px" }} />
- </Match>
- </Switch>
- </div>
- <div data-slot="card-details">
- <Switch
- fallback={
- <Show when={balanceInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
- <span data-slot="secret">••••</span>
- <span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
- </Show>
- }
- >
- <Match when={balanceInfo()?.paymentMethodType === "link"}>
- <span data-slot="type">Linked to Stripe</span>
- </Match>
- </Switch>
- </div>
+ <div data-slot="balance-display">
+ <div data-slot="balance-amount">
+ <span data-slot="balance-value">
+ ${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}
+ </span>
+ <span data-slot="balance-label">Current Balance</span>
</div>
- <div data-slot="button-row">
- <Show
- when={balanceInfo()?.reload}
- fallback={
- <Show
- when={hasBalance()}
- fallback={
- <button
- data-color="primary"
- disabled={createCheckoutUrlSubmission.pending}
- onClick={async () => {
- const baseUrl = window.location.href
- const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
- if (checkoutUrl) {
- window.location.href = checkoutUrl
- }
- }}
- >
- {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
- </button>
- }
- >
- <form action={setReload} method="post" data-slot="create-form">
- <input type="hidden" name="workspaceID" value={params.id} />
- <input type="hidden" name="reload" value="true" />
- <button data-color="primary" type="submit" disabled={setReloadSubmission.pending}>
- {setReloadSubmission.pending ? "Enabling..." : "Enable Billing"}
- </button>
- </form>
- </Show>
- }
- >
+ <Show when={balanceInfo()?.paymentMethodType}>
+ <div data-slot="balance-right-section">
<button
data-color="primary"
- disabled={createSessionUrlSubmission.pending}
+ disabled={createCheckoutUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
- const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
- if (sessionUrl) {
- window.location.href = sessionUrl
+ const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
+ if (checkoutUrl) {
+ window.location.href = checkoutUrl
}
}}
>
- {createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
+ {createCheckoutUrlSubmission.pending ? "Loading..." : "Add Balance"}
</button>
- <form action={setReload} method="post" data-slot="create-form">
- <input type="hidden" name="workspaceID" value={params.id} />
- <input type="hidden" name="reload" value="false" />
- <button data-color="ghost" type="submit" disabled={setReloadSubmission.pending}>
- {setReloadSubmission.pending ? "Disabling..." : "Disable"}
+ <div data-slot="credit-card">
+ <div data-slot="card-icon">
+ <Switch fallback={<IconCreditCard style={{ width: "24px", height: "24px" }} />}>
+ <Match when={balanceInfo()?.paymentMethodType === "link"}>
+ <IconStripe style={{ width: "24px", height: "24px" }} />
+ </Match>
+ </Switch>
+ </div>
+ <div data-slot="card-details">
+ <Switch>
+ <Match when={balanceInfo()?.paymentMethodType === "card"}>
+ <Show
+ when={balanceInfo()?.paymentMethodLast4}
+ fallback={<span data-slot="number">----</span>}
+ >
+ <span data-slot="secret">••••</span>
+ <span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
+ </Show>
+ </Match>
+ <Match when={balanceInfo()?.paymentMethodType === "link"}>
+ <span data-slot="type">Linked to Stripe</span>
+ </Match>
+ </Switch>
+ </div>
+ <button
+ data-color="ghost"
+ disabled={createSessionUrlSubmission.pending}
+ onClick={async () => {
+ const baseUrl = window.location.href
+ const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
+ if (sessionUrl) {
+ window.location.href = sessionUrl
+ }
+ }}
+ >
+ {createSessionUrlSubmission.pending ? "Loading..." : "Manage"}
</button>
- </form>
- </Show>
- </div>
- </div>
- <div data-slot="usage">
- <Show when={!balanceInfo()?.reload}>
- <Show
- when={hasBalance()}
- fallback={
- <p>
- We'll load <b>$20</b> (+$1.23 processing fee) and reload it when it reaches <b>$5</b>.
- </p>
- }
- >
- <p>
- You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in
- your account. You can continue using the API with your remaining balance.
- </p>
- </Show>
- </Show>
- <Show when={balanceInfo()?.reload && !balanceInfo()?.reloadError}>
- <p>
- Your current balance is <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
- . We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches <b>$5</b>.
- </p>
+ </div>
+ </div>
</Show>
</div>
+ <Show when={!balanceInfo()?.paymentMethodType}>
+ <button
+ data-slot="enable-billing-button"
+ data-color="primary"
+ disabled={createCheckoutUrlSubmission.pending}
+ onClick={async () => {
+ const baseUrl = window.location.href
+ const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
+ if (checkoutUrl) {
+ window.location.href = checkoutUrl
+ }
+ }}
+ >
+ {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
+ </button>
+ </Show>
</div>
</section>
)
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
index a6d4825bf..ad432a8aa 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
@@ -1,21 +1,26 @@
import { MonthlyLimitSection } from "./monthly-limit-section"
import { BillingSection } from "./billing-section"
+import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section"
import { Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
-import { querySessionInfo } from "../../common"
+import { queryBillingInfo, querySessionInfo } from "../../common"
export default function () {
const params = useParams()
const userInfo = createAsync(() => querySessionInfo(params.id))
+ const billingInfo = createAsync(() => queryBillingInfo(params.id))
return (
<div data-page="workspace-[id]">
<div data-slot="sections">
<Show when={userInfo()?.isAdmin}>
<BillingSection />
- <MonthlyLimitSection />
- <PaymentSection />
+ <Show when={billingInfo()?.paymentMethodType}>
+ <ReloadSection />
+ <MonthlyLimitSection />
+ <PaymentSection />
+ </Show>
</Show>
</div>
</div>
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css
new file mode 100644
index 000000000..97646269f
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css
@@ -0,0 +1,53 @@
+.root {
+ [data-slot="section-content"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ }
+
+ [data-slot="setting-row"] {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+
+ p {
+ flex: 1;
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+ color: var(--color-text-secondary);
+ margin: 0;
+
+ b {
+ font-weight: 600;
+ }
+ }
+
+ [data-slot="create-form"] {
+ margin: 0;
+ }
+ }
+
+ [data-slot="reload-error"] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+ margin-top: var(--space-4);
+
+ 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;
+ }
+ }
+}
+
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
new file mode 100644
index 000000000..4b55c97a9
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
@@ -0,0 +1,107 @@
+import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
+import { Show } from "solid-js"
+import { withActor } from "~/context/auth.withActor"
+import { Billing } from "@opencode-ai/console-core/billing.js"
+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"
+
+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 setReload = action(async (form: FormData) => {
+ "use server"
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ const reloadValue = form.get("reload")?.toString() === "true"
+ return json(
+ await Database.use((tx) =>
+ tx
+ .update(BillingTable)
+ .set({
+ reload: reloadValue,
+ ...(reloadValue ? { reloadError: null, timeReloadError: null } : {}),
+ })
+ .where(eq(BillingTable.workspaceID, workspaceID)),
+ ),
+ { revalidate: getBillingInfo.key },
+ )
+}, "billing.setReload")
+
+const getBillingInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ return await Billing.get()
+ }, workspaceID)
+}, "billing.get")
+
+export function ReloadSection() {
+ const params = useParams()
+ const balanceInfo = createAsync(() => getBillingInfo(params.id))
+ const setReloadSubmission = useSubmission(setReload)
+ const reloadSubmission = useSubmission(reload)
+
+ return (
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>Auto Reload</h2>
+ <p>Automatically reload your balance when it gets low.</p>
+ </div>
+ <div data-slot="section-content">
+ <div data-slot="setting-row">
+ <Show
+ when={balanceInfo()?.reload}
+ fallback={
+ <p>Auto reload is disabled. Enable to automatically reload when balance is low.</p>
+ }
+ >
+ <p>
+ We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches{" "}
+ <b>$5</b>.
+ </p>
+ </Show>
+ <form action={setReload} method="post" data-slot="create-form">
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <input type="hidden" name="reload" value={balanceInfo()?.reload ? "false" : "true"} />
+ <button data-color="primary" type="submit" disabled={setReloadSubmission.pending}>
+ <Show
+ when={balanceInfo()?.reload}
+ fallback={setReloadSubmission.pending ? "Enabling..." : "Enable"}
+ >
+ {setReloadSubmission.pending ? "Disabling..." : "Disable"}
+ </Show>
+ </button>
+ </form>
+ </div>
+ <Show when={balanceInfo()?.reload && balanceInfo()?.reloadError}>
+ <div data-slot="reload-error">
+ <p>
+ 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.
+ </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"}
+ </button>
+ </form>
+ </div>
+ </Show>
+ </div>
+ </section>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx
index 15839666a..69bfebe97 100644
--- a/packages/console/app/src/routes/workspace/common.tsx
+++ b/packages/console/app/src/routes/workspace/common.tsx
@@ -62,15 +62,21 @@ export const querySessionInfo = query(async (workspaceID: string) => {
return withActor(() => {
return {
isAdmin: Actor.userRole() === "admin",
- isBeta: Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true,
+ isBeta:
+ Resource.App.stage === "production"
+ ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y"
+ : true,
}
}, workspaceID)
}, "session.get")
-export const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
- "use server"
- return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
-}, "checkoutUrl")
+export const createCheckoutUrl = action(
+ async (workspaceID: string, successUrl: string, cancelUrl: string) => {
+ "use server"
+ return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
+ },
+ "checkoutUrl",
+)
export const queryBillingInfo = query(async (workspaceID: string) => {
"use server"