summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src/routes/workspace
diff options
context:
space:
mode:
authorFrank <[email protected]>2026-04-18 16:26:58 -0400
committerFrank <[email protected]>2026-04-18 17:33:28 -0400
commit9d012b062186ef9900cc2673b77c446d38ebd789 (patch)
tree0185f564ac585be5e50e1ba090da8a461e6fb4fc /packages/console/app/src/routes/workspace
parentfbb0a93e12740c7fb3f5f7ff62eee027c157e351 (diff)
downloadopencode-9d012b062186ef9900cc2673b77c446d38ebd789.tar.gz
opencode-9d012b062186ef9900cc2673b77c446d38ebd789.zip
zen: redeem credit
Diffstat (limited to 'packages/console/app/src/routes/workspace')
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/index.tsx2
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/redeem-section.module.css61
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/redeem-section.tsx71
3 files changed, 134 insertions, 0 deletions
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 4a7dc2488..fb4848535 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
@@ -3,6 +3,7 @@ import { BillingSection } from "./billing-section"
import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
+import { RedeemSection } from "./redeem-section"
import { createMemo, Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common"
@@ -26,6 +27,7 @@ export default function () {
<MonthlyLimitSection />
<PaymentSection />
</Show>
+ <RedeemSection />
</Show>
</div>
</div>
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.module.css
new file mode 100644
index 000000000..42140e4e8
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.module.css
@@ -0,0 +1,61 @@
+.root {
+ [data-slot="redeem-container"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ min-width: 20rem;
+ width: fit-content;
+
+ @media (max-width: 30rem) {
+ width: 100%;
+ }
+ }
+
+ [data-slot="redeem-form"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+
+ [data-slot="input-row"] {
+ display: flex;
+ gap: var(--space-2);
+ align-items: stretch;
+
+ @media (max-width: 30rem) {
+ flex-direction: column;
+ }
+ }
+
+ 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-error"] {
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+ }
+
+ [data-slot="form-success"] {
+ color: var(--color-success, var(--color-accent));
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+ }
+ }
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.tsx
new file mode 100644
index 000000000..872e8954b
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.tsx
@@ -0,0 +1,71 @@
+import { json, action, useParams, 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 { User } from "@opencode-ai/console-core/user.js"
+import { Actor } from "@opencode-ai/console-core/actor.js"
+import { CouponType } from "@opencode-ai/console-core/schema/billing.sql.js"
+import styles from "./redeem-section.module.css"
+import { queryBillingInfo } from "../../common"
+import { useI18n } from "~/context/i18n"
+import { formError, localizeError } from "~/lib/form-error"
+
+const redeem = action(async (form: FormData) => {
+ "use server"
+ const workspaceID = form.get("workspaceID") as string | null
+ if (!workspaceID) return { error: formError.workspaceRequired }
+ const code = (form.get("code") as string | null)?.trim().toUpperCase()
+ if (!code) return { error: "Coupon code is required." }
+ if (!(CouponType as readonly string[]).includes(code)) return { error: "Invalid coupon code." }
+
+ return json(
+ await withActor(async () => {
+ const actor = Actor.assert("user")
+ const email = await User.getAuthEmail(actor.properties.userID)
+ if (!email) return { error: "No email on account." }
+ return Billing.redeemCoupon(email, code as (typeof CouponType)[number])
+ .then(() => ({ error: undefined, data: true }))
+ .catch((e) => ({ error: e.message as string }))
+ }, workspaceID),
+ { revalidate: queryBillingInfo.key },
+ )
+}, "billing.redeemCoupon")
+
+export function RedeemSection() {
+ const params = useParams()
+ const i18n = useI18n()
+ const submission = useSubmission(redeem)
+
+ return (
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>{i18n.t("workspace.redeem.title")}</h2>
+ <p>{i18n.t("workspace.redeem.subtitle")}</p>
+ </div>
+ <div data-slot="redeem-container">
+ <form action={redeem} method="post" data-slot="redeem-form">
+ <div data-slot="input-row">
+ <input
+ required
+ data-component="input"
+ name="code"
+ type="text"
+ autocomplete="off"
+ placeholder={i18n.t("workspace.redeem.placeholder")}
+ />
+ <button type="submit" data-color="primary" disabled={submission.pending}>
+ {submission.pending ? i18n.t("workspace.redeem.redeeming") : i18n.t("workspace.redeem.redeem")}
+ </button>
+ </div>
+ <Show when={submission.result && (submission.result as any).error}>
+ {(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
+ </Show>
+ <Show when={submission.result && !(submission.result as any).error && (submission.result as any).data}>
+ <div data-slot="form-success">{i18n.t("workspace.redeem.success")}</div>
+ </Show>
+ <input type="hidden" name="workspaceID" value={params.id} />
+ </form>
+ </div>
+ </section>
+ )
+}