diff options
| author | Frank <[email protected]> | 2026-04-18 16:26:58 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2026-04-18 17:33:28 -0400 |
| commit | 9d012b062186ef9900cc2673b77c446d38ebd789 (patch) | |
| tree | 0185f564ac585be5e50e1ba090da8a461e6fb4fc /packages/console/app/src/routes/workspace | |
| parent | fbb0a93e12740c7fb3f5f7ff62eee027c157e351 (diff) | |
| download | opencode-9d012b062186ef9900cc2673b77c446d38ebd789.tar.gz opencode-9d012b062186ef9900cc2673b77c446d38ebd789.zip | |
zen: redeem credit
Diffstat (limited to 'packages/console/app/src/routes/workspace')
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> + ) +} |
