From 4bde3f7b156729733c0286a8119825d5782d8a31 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 31 Oct 2025 12:22:17 -0400 Subject: [PATCH] zen: billing page layout --- .../[id]/billing/billing-section.module.css | 113 +++++---- .../[id]/billing/billing-section.tsx | 228 ++++++------------ .../routes/workspace/[id]/billing/index.tsx | 11 +- .../[id]/billing/reload-section.module.css | 53 ++++ .../workspace/[id]/billing/reload-section.tsx | 107 ++++++++ .../app/src/routes/workspace/common.tsx | 16 +- 6 files changed, 316 insertions(+), 212 deletions(-) create mode 100644 packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx 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 b562dcd4..e0a80ef7 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); } } + + button { + white-space: nowrap; + flex-shrink: 0; + } } - [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; - } + 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 af4a47e4..7cbf29ad 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 (

Billing

- Manage payments methods. Contact us if you have any questions. + 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. -

-
- - -
+
+
+ + ${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()} + + Current Balance
- -
-
-
- }> - - - - -
-
- ----}> - •••• - {balanceInfo()?.paymentMethodLast4} - - } - > - - Linked to Stripe - - -
-
-
- { - const baseUrl = window.location.href - const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl) - if (checkoutUrl) { - window.location.href = checkoutUrl - } - }} - > - {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"} - - } - > -
- - - -
-
- } - > + +
-
- - - -
- -
+
+
+
-
- - - We'll load $20 (+$1.23 processing fee) and reload it when it reaches $5. -

+ +
+ }} + > + {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"} + +
) 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 a6d4825b..ad432a8a 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 (
- - + + + + +
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 00000000..97646269 --- /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 00000000..4b55c97a --- /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 ( +
+
+

Auto Reload

+

Automatically reload your balance when it gets low.

+
+
+
+ Auto reload is disabled. Enable to automatically reload when balance is low.

+ } + > +

+ We'll automatically reload $20 (+$1.23 processing fee) when it reaches{" "} + $5. +

+
+
+ + + +
+
+ +
+

+ 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. +

+
+ + +
+
+
+
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx index 15839666..69bfebe9 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"