summaryrefslogtreecommitdiffhomepage
path: root/cloud
diff options
context:
space:
mode:
authorJay V <[email protected]>2025-09-15 19:02:47 -0400
committerJay V <[email protected]>2025-09-15 19:02:51 -0400
commit74f9fcea8829d1fa7cb7c1f87230718605e54358 (patch)
tree9753510129a2e1dd9225b25634e7c8964bf07489 /cloud
parentbc213e1a619027b4cd91bda267b0c55f80ff453a (diff)
downloadopencode-74f9fcea8829d1fa7cb7c1f87230718605e54358.tar.gz
opencode-74f9fcea8829d1fa7cb7c1f87230718605e54358.zip
ignore: zen
Diffstat (limited to 'cloud')
-rw-r--r--cloud/app/src/routes/workspace/[id].css181
-rw-r--r--cloud/app/src/routes/workspace/[id].tsx291
2 files changed, 306 insertions, 166 deletions
diff --git a/cloud/app/src/routes/workspace/[id].css b/cloud/app/src/routes/workspace/[id].css
index 3f4db713e..b08ae24d7 100644
--- a/cloud/app/src/routes/workspace/[id].css
+++ b/cloud/app/src/routes/workspace/[id].css
@@ -269,9 +269,39 @@
}
}
- /* Balance Section */
- [data-component="balance-section"] {
- [data-slot="balance"] {
+ /* Billing Section */
+ [data-component="billing-section"] {
+ [data-slot="section-content"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ }
+
+ [data-slot="reload-error"] {
+ display: flex;
+ 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);
+ 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;
+ }
+ }
+ [data-slot="payment"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
@@ -281,23 +311,93 @@
min-width: 14.5rem;
width: fit-content;
- [data-slot="amount"] {
+ [data-slot="credit-card"] {
padding: var(--space-3-5) var(--space-4);
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
display: flex;
- align-items: baseline;
- gap: var(--space-1);
- justify-content: flex-end;
+ align-items: center;
+ justify-content: space-between;
- &[data-state="danger"] {
- [data-slot="value"] {
- color: var(--color-danger);
+ [data-slot="card-icon"] {
+ display: flex;
+ align-items: center;
+ }
+
+ [data-slot="card-details"] {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-1);
+
+ [data-slot="secret"] {
+ position: relative;
+ bottom: 2px;
+ font-size: var(--font-size-lg);
+ color: var(--color-text-muted);
+ font-weight: 400;
}
- [data-slot="currency"] {
- color: var(--color-danger);
+
+ [data-slot="number"] {
+ font-size: var(--font-size-3xl);
+ font-weight: 500;
+ color: var(--color-text);
}
}
+ }
+
+ [data-slot="button-row"] {
+ display: flex;
+ gap: var(--space-2);
+ align-items: center;
+
+ [data-slot="create-form"] {
+ margin: 0;
+ }
+
+ /* Make Enable Billing button full width when it's the only button */
+ > button {
+ flex: 1;
+ }
+ }
+ }
+ [data-slot="usage"] {
+ p {
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+ color: var(--color-text-secondary);
+ b {
+ font-weight: 600;
+ }
+ }
+ }
+ }
+
+ /* Monthly Limit Section */
+ [data-component="monthly-limit-section"] {
+ [data-slot="section-content"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ }
+
+ [data-slot="balance"] {
+ 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: 15rem;
+ width: fit-content;
+
+ [data-slot="amount"] {
+ padding: var(--space-3-5) var(--space-4);
+ background-color: var(--color-bg-surface);
+ border-radius: var(--border-radius-sm);
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-1);
+ justify-content: flex-end;
[data-slot="currency"] {
position: relative;
@@ -313,6 +413,63 @@
color: var(--color-text);
}
}
+
+ [data-slot="create-form"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ margin-top: var(--space-1);
+
+ [data-slot="input-container"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ }
+
+ @media (max-width: 30rem) {
+ gap: var(--space-2);
+ }
+
+ 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="form-actions"] {
+ display: flex;
+ gap: var(--space-2);
+ justify-content: flex-end;
+ }
+
+ [data-slot="form-error"] {
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ margin-top: var(--space-1);
+ line-height: 1.4;
+ }
+ }
+ }
+
+ [data-slot="usage-status"] {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ margin: 0;
+ line-height: 1.4;
}
}
diff --git a/cloud/app/src/routes/workspace/[id].tsx b/cloud/app/src/routes/workspace/[id].tsx
index 5844f1af4..4d525b33d 100644
--- a/cloud/app/src/routes/workspace/[id].tsx
+++ b/cloud/app/src/routes/workspace/[id].tsx
@@ -278,140 +278,122 @@ function KeyCreateForm() {
)
}
-function BalanceSection() {
+function BillingSection() {
const params = useParams()
const balanceInfo = createAsync(() => getBillingInfo(params.id))
const createCheckoutUrlAction = useAction(createCheckoutUrl)
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
+ const createSessionUrlAction = useAction(createSessionUrl)
+ const createSessionUrlSubmission = useSubmission(createSessionUrl)
const disableReloadSubmission = useSubmission(disableReload)
const reloadSubmission = useSubmission(reload)
+ const balanceAmount = createMemo(() => {
+ return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
+ })
+
return (
- <section data-component="balance-section">
+ <section data-component="billing-section">
<div data-slot="section-title">
- <h2>Balance</h2>
- <p>Add credits to your account.</p>
+ <h2>Billing</h2>
+ <p>Manage the payment method for your account.</p>
</div>
- <div data-slot="balance">
- <div
- data-slot="amount"
- data-state={(() => {
- const balanceStr = ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
- return balanceStr === "0.00" || balanceStr === "-0.00" ? "danger" : undefined
- })()}
- >
- <span data-slot="currency">$</span>
- <span data-slot="value">
- {(() => {
- const balanceStr = ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
- return balanceStr === "-0.00" ? "0.00" : balanceStr
- })()}
- </span>
- </div>
- <Show
- when={balanceInfo()?.reload}
- fallback={
- <>
+ <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">
+ <IconCreditCard style={{ width: "32px", height: "32px" }} />
+ </div>
+ <div data-slot="card-details">
+ <Show when={balanceInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
+ <span data-slot="secret">••••</span>
+ <span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
+ </Show>
+ </div>
+ </div>
+ <div data-slot="button-row">
+ <Show
+ when={balanceInfo()?.reload}
+ 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>
+ }
+ >
<button
data-color="primary"
- disabled={createCheckoutUrlSubmission.pending}
+ disabled={createSessionUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
- const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
- if (checkoutUrl) {
- window.location.href = checkoutUrl
+ const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
+ if (sessionUrl) {
+ window.location.href = sessionUrl
}
}}
>
- {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
+ {createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
</button>
- <p>You can continue using the API with the remaining credits.</p>
- </>
- }
- >
- <>
- <div>
- <p>
- You will be automatically reloading <b>$20</b> (+$1.23 processing fee) when your balance reaches{" "}
- <b>$5</b>.
- </p>
- <p>You will be able to continue using the API with the remaining credits after disabling billing.</p>
<form action={disableReload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
- <button data-color="primary" type="submit" disabled={disableReloadSubmission.pending}>
- {disableReloadSubmission.pending ? "Disabling..." : "Disable Billing"}
+ <button data-color="ghost" type="submit" disabled={disableReloadSubmission.pending}>
+ {disableReloadSubmission.pending ? "Disabling..." : "Disable"}
</button>
</form>
- <Show when={balanceInfo()?.reloadError}>
- <>
- <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..." : "Retry Reload"}
- </button>
- </form>
- </>
- </Show>
- </div>
- </>
- </Show>
- </div>
- <Show when={balanceInfo()?.reload}>
- <BalancePaymentForm />
- <BalanceLimitForm />
- </Show>
- </section>
- )
-}
-
-function BalancePaymentForm() {
- const params = useParams()
- const createSessionUrlAction = useAction(createSessionUrl)
- const createSessionUrlSubmission = useSubmission(createSessionUrl)
- const balanceInfo = createAsync(() => getBillingInfo(params.id))
-
- return (
- <>
- <div data-slot="section-title">
- <h2>Payment Method</h2>
- </div>
- <div data-slot="balance">
- <div data-slot="amount">
- <IconCreditCard style={{ width: "32px", height: "32px" }} />
- <span data-slot="currency">••••</span>
- <span data-slot="value">{balanceInfo()?.paymentMethodLast4}</span>
+ </Show>
+ </div>
+ </div>
+ <div data-slot="usage">
+ <Show when={!balanceInfo()?.reload && !(balanceAmount() === "0.00" || balanceAmount() === "-0.00")}>
+ <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 when={balanceInfo()?.reload && !balanceInfo()?.reloadError}>
+ <p>
+ Your current balance is <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
+ . It'll be reloaded to <b>$20</b> (+$1.23 processing fee) when it reaches <b>$5</b>.
+ </p>
+ </Show>
</div>
- <button
- data-color="primary"
- 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 Payment Methods"}
- </button>
</div>
- </>
+ </section>
)
}
-function BalanceLimitForm() {
+function MonthlyLimitSection() {
const params = useParams()
const submission = useSubmission(setMonthlyLimit)
const [store, setStore] = createStore({ show: false })
@@ -444,19 +426,47 @@ function BalanceLimitForm() {
}
return (
- <>
+ <section data-component="monthly-limit-section">
<div data-slot="section-title">
<h2>Monthly Limit</h2>
+ <p>Set a monthly spending limit for your account.</p>
</div>
- <div data-slot="balance">
- <div data-slot="amount">
- <span data-slot="currency">$</span>
- <span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span>
+ <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>
+ </div>
+ <Show
+ when={!store.show}
+ fallback={
+ <form action={setMonthlyLimit} method="post" data-slot="create-form">
+ <div data-slot="input-container">
+ <input ref={(r) => (input = r)} data-component="input" name="limit" type="number" placeholder="50" />
+ <Show when={submission.result && submission.result.error}>
+ {(err) => <div data-slot="form-error">{err()}</div>}
+ </Show>
+ </div>
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <div data-slot="form-actions">
+ <button type="reset" data-color="ghost" onClick={() => hide()}>
+ Cancel
+ </button>
+ <button type="submit" data-color="primary" disabled={submission.pending}>
+ {submission.pending ? "Setting..." : "Set"}
+ </button>
+ </div>
+ </form>
+ }
+ >
+ <button data-color="primary" onClick={() => show()}>
+ {balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
+ </button>
+ </Show>
</div>
- <Show when={balanceInfo()?.monthlyLimit} fallback={<p>No spending limit set.</p>}>
- <p>
- Current usage for the month of {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })}{" "}
- is $
+ <Show when={balanceInfo()?.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 $
{(() => {
const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated
if (!dateLastUsed) return "0"
@@ -474,42 +484,11 @@ function BalanceLimitForm() {
if (current !== lastUsed) return "0"
return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
})()}
+ .
</p>
</Show>
</div>
- <Show
- when={store.show}
- fallback={
- <button data-color="primary" onClick={() => show()}>
- {balanceInfo()?.monthlyLimit ? "Edit Spending Limit" : "Set Spending Limit"}
- </button>
- }
- >
- <form action={setMonthlyLimit} method="post" data-slot="create-form">
- <div data-slot="input-container">
- <input
- ref={(r) => (input = r)}
- data-component="input"
- name="limit"
- type="number"
- placeholder="Enter limit"
- />
- <Show when={submission.result && submission.result.error}>
- {(err) => <div data-slot="form-error">{err()}</div>}
- </Show>
- </div>
- <input type="hidden" name="workspaceID" value={params.id} />
- <div data-slot="form-actions">
- <button type="reset" data-color="ghost" onClick={() => hide()}>
- Cancel
- </button>
- <button type="submit" data-color="primary" disabled={submission.pending}>
- {submission.pending ? "Setting..." : "Set"}
- </button>
- </div>
- </form>
- </Show>
- </>
+ </section>
)
}
@@ -570,7 +549,6 @@ function UsageSection() {
function PaymentSection() {
const params = useParams()
const payments = createAsync(() => getPaymentsInfo(params.id))
- console.log("!#!@", payments())
return (
payments() &&
@@ -675,13 +653,13 @@ function NewUserSection() {
<div data-component="next-steps">
<ol>
+ <li>Enable billing</li>
<li>
Run <code>opencode auth login</code> and select opencode
</li>
<li>Paste your API key</li>
- <li>Start opencode</li>
<li>
- Run <code>/models</code> to see available models
+ Start opencode and run <code>/models</code> to select a model
</li>
</ol>
</div>
@@ -690,7 +668,9 @@ function NewUserSection() {
)
}
-export default function () {
+export default function() {
+ const params = useParams()
+
return (
<div data-page="workspace-[id]">
<section data-component="title-section">
@@ -707,7 +687,10 @@ export default function () {
<div data-slot="sections">
<NewUserSection />
<KeySection />
- <BalanceSection />
+ <BillingSection />
+ <Show when={createAsync(() => getBillingInfo(params.id))()?.reload}>
+ <MonthlyLimitSection />
+ </Show>
<UsageSection />
<PaymentSection />
</div>