summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src/routes/workspace
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-11-04 16:51:38 -0500
committerFrank <[email protected]>2025-11-04 17:24:20 -0500
commit8d6a03cc898c0b982b7a05419d41a07e8db579f8 (patch)
treebb881e3aac7d5984dc8e99b78a08a4e353b283d7 /packages/console/app/src/routes/workspace
parent71b04ffa99a3215316083dcf4d5e15afb3193958 (diff)
downloadopencode-8d6a03cc898c0b982b7a05419d41a07e8db579f8.tar.gz
opencode-8d6a03cc898c0b982b7a05419d41a07e8db579f8.zip
zen: custom reload amount
Diffstat (limited to 'packages/console/app/src/routes/workspace')
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css51
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx187
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx32
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css202
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx162
-rw-r--r--packages/console/app/src/routes/workspace/[id]/index.tsx42
-rw-r--r--packages/console/app/src/routes/workspace/common.tsx33
7 files changed, 586 insertions, 123 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 e0a80ef74..aef008a48 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
@@ -71,6 +71,57 @@
flex: 1;
}
+ [data-slot="add-balance-form-container"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ }
+
+ [data-slot="add-balance-form"] {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--space-3);
+
+ label {
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ color: var(--color-text-muted);
+ white-space: nowrap;
+ }
+
+ input[data-component="input"] {
+ 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);
+ line-height: 1.5;
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px var(--color-accent-alpha);
+ }
+
+ &::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);
+ line-height: 1.4;
+ }
+
[data-slot="credit-card"] {
padding: var(--space-2) var(--space-4);
background-color: var(--color-bg-surface);
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 c0723136b..9e51bbe10 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,24 +1,80 @@
-import { action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
-import { createMemo, Match, Show, Switch } from "solid-js"
+import { action, useParams, useAction, createAsync, useSubmission, json } from "@solidjs/router"
+import { createMemo, Match, Show, Switch, createEffect } from "solid-js"
+import { createStore } from "solid-js/store"
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 { createCheckoutUrl, queryBillingInfo } from "../../common"
+import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
- return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID)
+ return json(
+ await withActor(
+ () =>
+ Billing.generateSessionUrl({ returnUrl })
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({
+ error: e.message as string,
+ data: undefined,
+ })),
+ workspaceID,
+ ),
+ { revalidate: queryBillingInfo.key },
+ )
}, "sessionUrl")
export function BillingSection() {
const params = useParams()
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
- const balanceInfo = createAsync(() => queryBillingInfo(params.id))
- const createCheckoutUrlAction = useAction(createCheckoutUrl)
- const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
- const createSessionUrlAction = useAction(createSessionUrl)
- const createSessionUrlSubmission = useSubmission(createSessionUrl)
+ const billingInfo = createAsync(() => queryBillingInfo(params.id))
+ const checkoutAction = useAction(createCheckoutUrl)
+ const checkoutSubmission = useSubmission(createCheckoutUrl)
+ const sessionAction = useAction(createSessionUrl)
+ const sessionSubmission = useSubmission(createSessionUrl)
+ const [store, setStore] = createStore({
+ showAddBalanceForm: false,
+ addBalanceAmount: "",
+ checkoutRedirecting: false,
+ sessionRedirecting: false,
+ })
+ const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0))
+
+ async function onClickCheckout() {
+ const amount = parseInt(store.addBalanceAmount)
+ const baseUrl = window.location.href
+
+ const checkout = await checkoutAction(params.id, amount, baseUrl, baseUrl)
+ if (checkout && checkout.data) {
+ setStore("checkoutRedirecting", true)
+ window.location.href = checkout.data
+ }
+ }
+
+ async function onClickSession() {
+ const baseUrl = window.location.href
+ const sessionUrl = await sessionAction(params.id, baseUrl)
+ if (sessionUrl && sessionUrl.data) {
+ setStore("sessionRedirecting", true)
+ window.location.href = sessionUrl.data
+ }
+ }
+
+ function showAddBalanceForm() {
+ while (true) {
+ checkoutSubmission.clear()
+ if (!checkoutSubmission.result) break
+ }
+ setStore({
+ showAddBalanceForm: true,
+ addBalanceAmount: billingInfo()!.reloadAmount.toString(),
+ })
+ }
+
+ function hideAddBalanceForm() {
+ setStore("showAddBalanceForm", false)
+ checkoutSubmission.clear()
+ }
// DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
@@ -72,10 +128,6 @@ export function BillingSection() {
// timeReloadError: null as Date | null
// })
- const balanceAmount = createMemo(() => {
- return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
- })
-
return (
<section class={styles.root}>
<div data-slot="section-title">
@@ -88,81 +140,110 @@ export function BillingSection() {
<div data-slot="section-content">
<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-value">${balance()}</span>
<span data-slot="balance-label">Current Balance</span>
</div>
- <Show when={balanceInfo()?.customerID}>
+ <Show when={billingInfo()?.customerID}>
<div data-slot="balance-right-section">
- <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
- }
- }}
+ <Show
+ when={!store.showAddBalanceForm}
+ fallback={
+ <div data-slot="add-balance-form-container">
+ <div data-slot="add-balance-form">
+ <label>Add $</label>
+ <input
+ data-component="input"
+ type="number"
+ min={billingInfo()?.reloadAmountMin.toString()}
+ step="1"
+ value={store.addBalanceAmount}
+ onInput={(e) => {
+ setStore("addBalanceAmount", e.currentTarget.value)
+ checkoutSubmission.clear()
+ }}
+ placeholder="Enter amount"
+ />
+ <div data-slot="form-actions">
+ <button
+ data-color="ghost"
+ type="button"
+ onClick={() => hideAddBalanceForm()}
+ >
+ Cancel
+ </button>
+ <button
+ data-color="primary"
+ type="button"
+ disabled={
+ !store.addBalanceAmount ||
+ checkoutSubmission.pending ||
+ store.checkoutRedirecting
+ }
+ onClick={onClickCheckout}
+ >
+ {checkoutSubmission.pending || store.checkoutRedirecting
+ ? "Loading..."
+ : "Add"}
+ </button>
+ </div>
+ </div>
+ <Show
+ when={checkoutSubmission.result && (checkoutSubmission.result as any).error}
+ >
+ {(err: any) => <div data-slot="form-error">{err()}</div>}
+ </Show>
+ </div>
+ }
>
- {createCheckoutUrlSubmission.pending ? "Loading..." : "Add Balance"}
- </button>
+ <button data-color="primary" onClick={() => showAddBalanceForm()}>
+ Add Balance
+ </button>
+ </Show>
<div data-slot="credit-card">
<div data-slot="card-icon">
<Switch fallback={<IconCreditCard style={{ width: "24px", height: "24px" }} />}>
- <Match when={balanceInfo()?.paymentMethodType === "link"}>
+ <Match when={billingInfo()?.paymentMethodType === "link"}>
<IconStripe style={{ width: "24px", height: "24px" }} />
</Match>
</Switch>
</div>
<div data-slot="card-details">
<Switch>
- <Match when={balanceInfo()?.paymentMethodType === "card"}>
+ <Match when={billingInfo()?.paymentMethodType === "card"}>
<Show
- when={balanceInfo()?.paymentMethodLast4}
+ when={billingInfo()?.paymentMethodLast4}
fallback={<span data-slot="number">----</span>}
>
<span data-slot="secret">••••</span>
- <span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
+ <span data-slot="number">{billingInfo()?.paymentMethodLast4}</span>
</Show>
</Match>
- <Match when={balanceInfo()?.paymentMethodType === "link"}>
+ <Match when={billingInfo()?.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
- }
- }}
+ disabled={sessionSubmission.pending || store.sessionRedirecting}
+ onClick={onClickSession}
>
- {createSessionUrlSubmission.pending ? "Loading..." : "Manage"}
+ {sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage"}
</button>
</div>
</div>
</Show>
</div>
- <Show when={!balanceInfo()?.customerID}>
+ <Show when={!billingInfo()?.customerID}>
<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
- }
- }}
+ disabled={checkoutSubmission.pending || store.checkoutRedirecting}
+ onClick={onClickCheckout}
>
- {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
+ {checkoutSubmission.pending || store.checkoutRedirecting
+ ? "Loading..."
+ : "Enable Billing"}
</button>
</Show>
</div>
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx
index dbeda115c..b28b072d5 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx
@@ -1,16 +1,10 @@
-import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
+import { json, 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-ai/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")
+import { queryBillingInfo } from "../../common"
const setMonthlyLimit = action(async (form: FormData) => {
"use server"
@@ -28,7 +22,7 @@ const setMonthlyLimit = action(async (form: FormData) => {
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
- { revalidate: getBillingInfo.key },
+ { revalidate: queryBillingInfo.key },
)
}, "billing.setMonthlyLimit")
@@ -36,7 +30,7 @@ export function MonthlyLimitSection() {
const params = useParams()
const submission = useSubmission(setMonthlyLimit)
const [store, setStore] = createStore({ show: false })
- const balanceInfo = createAsync(() => getBillingInfo(params.id))
+ const billingInfo = createAsync(() => queryBillingInfo(params.id))
let input: HTMLInputElement
@@ -73,8 +67,8 @@ export function MonthlyLimitSection() {
<div data-slot="section-content">
<div data-slot="balance">
<div data-slot="amount">
- {balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
- <span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span>
+ {billingInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
+ <span data-slot="value">{billingInfo()?.monthlyLimit ?? "-"}</span>
</div>
<Show
when={!store.show}
@@ -106,15 +100,19 @@ export function MonthlyLimitSection() {
}
>
<button data-color="primary" onClick={() => show()}>
- {balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
+ {billingInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
</button>
</Show>
</div>
- <Show when={balanceInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No spending limit set.</p>}>
+ <Show
+ when={billingInfo()?.monthlyLimit}
+ fallback={<p data-slot="usage-status">No spending limit set.</p>}
+ >
<p data-slot="usage-status">
- Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
+ Current usage for{" "}
+ {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
{(() => {
- const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated
+ const dateLastUsed = billingInfo()?.timeMonthlyUsageUpdated
if (!dateLastUsed) return "0"
const current = new Date().toLocaleDateString("en-US", {
@@ -128,7 +126,7 @@ export function MonthlyLimitSection() {
timeZone: "UTC",
})
if (current !== lastUsed) return "0"
- return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
+ return ((billingInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
})()}
.
</p>
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
index 08fb8524b..11ab789b2 100644
--- 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
@@ -34,6 +34,206 @@
}
}
+ [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);
+ margin-top: var(--space-4);
+
+ [data-slot="form-field"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+
+ label {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ }
+
+ [data-slot="field-label"] {
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ color: var(--color-text-muted);
+ }
+
+ [data-slot="toggle-container"] {
+ display: flex;
+ align-items: center;
+ }
+
+ input[data-component="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="input-row"] {
+ display: flex;
+ flex-direction: row;
+ gap: var(--space-3);
+
+ @media (max-width: 40rem) {
+ flex-direction: column;
+ gap: var(--space-2);
+ }
+ }
+
+ [data-slot="input-field"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ flex: 1;
+
+ p {
+ line-height: 1.2;
+ margin: 0;
+ color: var(--color-text-muted);
+ font-size: var(--font-size-sm);
+ }
+
+ input[data-component="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);
+ line-height: 1.5;
+ min-width: 0;
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px var(--color-accent-alpha);
+ }
+
+ &::placeholder {
+ color: var(--color-text-disabled);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ background-color: var(--color-bg-surface);
+ }
+ }
+
+ [data-slot="field-with-connector"] {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+
+ [data-slot="field-connector"] {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ white-space: nowrap;
+ }
+
+ input[data-component="input"] {
+ flex: 1;
+ min-width: 80px;
+ }
+ }
+ }
+
+ [data-slot="form-actions"] {
+ display: flex;
+ gap: var(--space-2);
+ margin-top: var(--space-1);
+ }
+
+ [data-slot="form-error"] {
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+ margin-top: calc(var(--space-1) * -1);
+ }
+
+ [data-slot="model-toggle-label"] {
+ position: relative;
+ display: inline-block;
+ width: 2.5rem;
+ height: 1.5rem;
+ cursor: pointer;
+
+ input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+ }
+
+ span {
+ position: absolute;
+ inset: 0;
+ background-color: #ccc;
+ border: 1px solid #bbb;
+ border-radius: 1.5rem;
+ transition: all 0.3s ease;
+ cursor: pointer;
+
+ &::before {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 0.125rem;
+ width: 1.25rem;
+ height: 1.25rem;
+ background-color: white;
+ border: 1px solid #ddd;
+ border-radius: 50%;
+ transform: translateY(-50%);
+ transition: all 0.3s ease;
+ }
+ }
+
+ input:checked + span {
+ background-color: #21ad0e;
+ border-color: #148605;
+
+ &::before {
+ transform: translateX(1rem) translateY(-50%);
+ }
+ }
+
+ &:hover span {
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
+ }
+
+ input:checked:hover + span {
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
+ }
+
+ &:has(input:disabled) {
+ cursor: not-allowed;
+ }
+
+ input:disabled + span {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+ }
+
[data-slot="reload-error"] {
display: flex;
align-items: center;
@@ -54,6 +254,8 @@
gap: var(--space-2);
margin: 0;
flex-shrink: 0;
+ padding: 0;
+ border: none;
}
}
}
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
index 6be6ddf31..57267a95e 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
@@ -1,17 +1,19 @@
-import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
-import { Show } from "solid-js"
+import { json, 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-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"
+import { queryBillingInfo } 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,
+ revalidate: queryBillingInfo.key,
})
}, "billing.reload")
@@ -20,12 +22,27 @@ const setReload = action(async (form: FormData) => {
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
const reloadValue = form.get("reload")?.toString() === "true"
+ const amountStr = form.get("reloadAmount")?.toString()
+ const triggerStr = form.get("reloadTrigger")?.toString()
+
+ const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null
+ const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null
+
+ if (reloadValue) {
+ if (reloadAmount === null || reloadAmount < Billing.RELOAD_AMOUNT_MIN)
+ return { error: `Reload amount must be at least $${Billing.RELOAD_AMOUNT_MIN}` }
+ if (reloadTrigger === null || reloadTrigger < Billing.RELOAD_TRIGGER_MIN)
+ return { error: `Balance trigger must be at least $${Billing.RELOAD_TRIGGER_MIN}` }
+ }
+
return json(
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
reload: reloadValue,
+ ...(reloadAmount !== null ? { reloadAmount } : {}),
+ ...(reloadTrigger !== null ? { reloadTrigger } : {}),
...(reloadValue
? {
reloadError: null,
@@ -35,22 +52,47 @@ const setReload = action(async (form: FormData) => {
})
.where(eq(BillingTable.workspaceID, workspaceID)),
),
- { revalidate: getBillingInfo.key },
+ { revalidate: queryBillingInfo.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 billingInfo = createAsync(() => queryBillingInfo(params.id))
const setReloadSubmission = useSubmission(setReload)
const reloadSubmission = useSubmission(reload)
+ const [store, setStore] = createStore({
+ show: false,
+ reload: false,
+ reloadAmount: "",
+ reloadTrigger: "",
+ })
+
+ createEffect(() => {
+ if (
+ !setReloadSubmission.pending &&
+ setReloadSubmission.result &&
+ !(setReloadSubmission.result as any).error
+ ) {
+ setStore("show", false)
+ }
+ })
+
+ function show() {
+ while (true) {
+ setReloadSubmission.clear()
+ if (!setReloadSubmission.result) break
+ }
+ const info = billingInfo()!
+ setStore("show", true)
+ setStore("reload", info.reload ? true : true)
+ setStore("reloadAmount", info.reloadAmount.toString())
+ setStore("reloadTrigger", info.reloadTrigger.toString())
+ }
+
+ function hide() {
+ setStore("show", false)
+ }
return (
<section class={styles.root}>
@@ -58,43 +100,101 @@ export function ReloadSection() {
<h2>Auto Reload</h2>
<div data-slot="title-row">
<Show
- when={balanceInfo()?.reload}
+ when={billingInfo()?.reload}
fallback={
- <p>Auto reload is disabled. Enable to automatically reload when balance is low.</p>
+ <p>
+ Auto reload is <b>disabled</b>. 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>.
+ Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b>{" "}
+ (+$1.23 processing fee) when balance reaches <b>${billingInfo()?.reloadTrigger}</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>
+ <button data-color="primary" type="button" onClick={() => show()}>
+ {billingInfo()?.reload ? "Edit" : "Enable"}
+ </button>
</div>
</div>
+ <Show when={store.show}>
+ <form action={setReload} method="post" data-slot="create-form">
+ <div data-slot="form-field">
+ <label>
+ <span data-slot="field-label">Enable Auto Reload</span>
+ <div data-slot="toggle-container">
+ <label data-slot="model-toggle-label">
+ <input
+ type="checkbox"
+ name="reload"
+ value="true"
+ checked={store.reload}
+ onChange={(e) => setStore("reload", e.currentTarget.checked)}
+ />
+ <span></span>
+ </label>
+ </div>
+ </label>
+ </div>
+
+ <div data-slot="input-row">
+ <div data-slot="input-field">
+ <p>Reload $</p>
+ <input
+ data-component="input"
+ name="reloadAmount"
+ type="number"
+ min={billingInfo()?.reloadAmountMin.toString()}
+ step="1"
+ value={store.reloadAmount}
+ onInput={(e) => setStore("reloadAmount", e.currentTarget.value)}
+ placeholder={billingInfo()?.reloadAmount.toString()}
+ disabled={!store.reload}
+ />
+ </div>
+ <div data-slot="input-field">
+ <p>When balance reaches $</p>
+ <input
+ data-component="input"
+ name="reloadTrigger"
+ type="number"
+ min={billingInfo()?.reloadTriggerMin.toString()}
+ step="1"
+ value={store.reloadTrigger}
+ onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)}
+ placeholder={billingInfo()?.reloadTrigger.toString()}
+ disabled={!store.reload}
+ />
+ </div>
+ </div>
+
+ <Show when={setReloadSubmission.result && (setReloadSubmission.result as any).error}>
+ {(err: any) => <div data-slot="form-error">{err()}</div>}
+ </Show>
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <div data-slot="form-actions">
+ <button type="button" data-color="ghost" onClick={() => hide()}>
+ Cancel
+ </button>
+ <button type="submit" data-color="primary" disabled={setReloadSubmission.pending}>
+ {setReloadSubmission.pending ? "Saving..." : "Save"}
+ </button>
+ </div>
+ </form>
+ </Show>
<div data-slot="section-content">
- <Show when={balanceInfo()?.reload && balanceInfo()?.reloadError}>
+ <Show when={billingInfo()?.reload && billingInfo()?.reloadError}>
<div data-slot="reload-error">
<p>
Reload failed at{" "}
- {balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
+ {billingInfo()?.timeReloadError!.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
})}
- . Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment
+ . Reason: {billingInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment
method and try again.
</p>
<form action={reload} method="post" data-slot="create-form">
diff --git a/packages/console/app/src/routes/workspace/[id]/index.tsx b/packages/console/app/src/routes/workspace/[id]/index.tsx
index 8f7678f21..2e7f7d64b 100644
--- a/packages/console/app/src/routes/workspace/[id]/index.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/index.tsx
@@ -1,22 +1,32 @@
+import { Show, createMemo } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
import { NewUserSection } from "./new-user-section"
import { UsageSection } from "./usage-section"
import { ModelSection } from "./model-section"
import { ProviderSection } from "./provider-section"
import { IconLogo } from "~/component/icon"
-import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
-import { querySessionInfo, queryBillingInfo, createCheckoutUrl } from "../common"
-import { Show, createMemo } from "solid-js"
+import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
export default function () {
const params = useParams()
const userInfo = createAsync(() => querySessionInfo(params.id))
const billingInfo = createAsync(() => queryBillingInfo(params.id))
- const createCheckoutUrlAction = useAction(createCheckoutUrl)
- const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
-
- const balanceAmount = createMemo(() => {
- return ((billingInfo()?.balance ?? 0) / 100000000).toFixed(2)
+ const checkoutAction = useAction(createCheckoutUrl)
+ const checkoutSubmission = useSubmission(createCheckoutUrl)
+ const [store, setStore] = createStore({
+ checkoutRedirecting: false,
})
+ const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0))
+
+ async function onClickCheckout() {
+ const baseUrl = window.location.href
+ const checkout = await checkoutAction(params.id, billingInfo()!.reloadAmount, baseUrl, baseUrl)
+ if (checkout && checkout.data) {
+ setStore("checkoutRedirecting", true)
+ window.location.href = checkout.data
+ }
+ }
return (
<div data-page="workspace-[id]">
@@ -38,21 +48,17 @@ export default function () {
<button
data-color="primary"
data-size="sm"
- disabled={createCheckoutUrlSubmission.pending}
- onClick={async () => {
- const baseUrl = window.location.href
- const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
- if (checkoutUrl) {
- window.location.href = checkoutUrl
- }
- }}
+ disabled={checkoutSubmission.pending || store.checkoutRedirecting}
+ onClick={onClickCheckout}
>
- {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable billing"}
+ {checkoutSubmission.pending || store.checkoutRedirecting
+ ? "Loading..."
+ : "Enable billing"}
</button>
}
>
<span data-slot="balance">
- Current balance <b>${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
+ Current balance <b>${balance()}</b>
</span>
</Show>
</span>
diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx
index 69bfebe97..5b638192c 100644
--- a/packages/console/app/src/routes/workspace/common.tsx
+++ b/packages/console/app/src/routes/workspace/common.tsx
@@ -1,6 +1,6 @@
import { Resource } from "@opencode-ai/console-resource"
import { Actor } from "@opencode-ai/console-core/actor.js"
-import { action, query } from "@solidjs/router"
+import { action, json, query } from "@solidjs/router"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { User } from "@opencode-ai/console-core/user.js"
@@ -34,6 +34,11 @@ export function formatDateUTC(date: Date) {
return date.toLocaleDateString("en-US", options)
}
+export function formatBalance(amount: number) {
+ const balance = ((amount ?? 0) / 100000000).toFixed(2)
+ return balance === "-0.00" ? "0.00" : balance
+}
+
export async function getLastSeenWorkspaceID() {
"use server"
return withActor(async () => {
@@ -71,14 +76,34 @@ export const querySessionInfo = query(async (workspaceID: string) => {
}, "session.get")
export const createCheckoutUrl = action(
- async (workspaceID: string, successUrl: string, cancelUrl: string) => {
+ async (workspaceID: string, amount: number, successUrl: string, cancelUrl: string) => {
"use server"
- return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
+ return json(
+ await withActor(
+ () =>
+ Billing.generateCheckoutUrl({ amount, successUrl, cancelUrl })
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({
+ error: e.message as string,
+ data: undefined,
+ })),
+ workspaceID,
+ ),
+ )
},
"checkoutUrl",
)
export const queryBillingInfo = query(async (workspaceID: string) => {
"use server"
- return withActor(() => Billing.get(), workspaceID)
+ return withActor(async () => {
+ const billing = await Billing.get()
+ return {
+ ...billing,
+ reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT,
+ reloadAmountMin: Billing.RELOAD_AMOUNT_MIN,
+ reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER,
+ reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN,
+ }
+ }, workspaceID)
}, "billing.get")