Browse Source

zen: billing page layout

Frank 3 months ago
parent
commit
4bde3f7b15

+ 64 - 47
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;
   }
 }

+ 72 - 156
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>
   )

+ 8 - 3
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>

+ 53 - 0
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;
+    }
+  }
+}
+

+ 107 - 0
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>
+  )
+}

+ 11 - 5
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"