diff options
| author | Frank <[email protected]> | 2025-10-31 12:22:17 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-10-31 14:43:51 -0400 |
| commit | 4bde3f7b156729733c0286a8119825d5782d8a31 (patch) | |
| tree | c9e544fe866eb46c8046c5f7dd5b2292d77d7c28 | |
| parent | 4355027408d246c28aa08435fda16d385c595b49 (diff) | |
| download | opencode-4bde3f7b156729733c0286a8119825d5782d8a31.tar.gz opencode-4bde3f7b156729733c0286a8119825d5782d8a31.zip | |
zen: billing page layout
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" |
