wip: zen black

This commit is contained in:
Frank
2026-01-22 16:59:32 -05:00
parent fdac21688c
commit 5f3ab9395f
17 changed files with 1697 additions and 418 deletions

View File

@@ -9,6 +9,7 @@ import { Black } from "@opencode-ai/console-core/black.js"
import { withActor } from "~/context/auth.withActor"
import { queryBillingInfo } from "../../common"
import styles from "./black-section.module.css"
import waitlistStyles from "./black-waitlist-section.module.css"
const querySubscription = query(async (workspaceID: string) => {
"use server"
@@ -27,7 +28,7 @@ const querySubscription = query(async (workspaceID: string) => {
.where(and(eq(SubscriptionTable.workspaceID, Actor.workspace()), isNull(SubscriptionTable.timeDeleted)))
.then((r) => r[0]),
)
if (!row.subscription) return null
if (!row?.subscription) return null
return {
plan: row.subscription.plan,
@@ -58,6 +59,37 @@ function formatResetTime(seconds: number) {
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`
}
const cancelWaitlist = action(async (workspaceID: string) => {
"use server"
return json(
await withActor(async () => {
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
subscriptionPlan: null,
timeSubscriptionBooked: null,
timeSubscriptionSelected: null,
})
.where(eq(BillingTable.workspaceID, workspaceID)),
)
return { error: undefined }
}, workspaceID).catch((e) => ({ error: e.message as string })),
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
)
}, "cancelWaitlist")
const enroll = action(async (workspaceID: string) => {
"use server"
return json(
await withActor(async () => {
await Billing.subscribe({ seats: 1 })
return { error: undefined }
}, workspaceID).catch((e) => ({ error: e.message as string })),
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
)
}, "enroll")
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return json(
@@ -71,17 +103,24 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
})),
workspaceID,
),
{ revalidate: queryBillingInfo.key },
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
)
}, "sessionUrl")
export function BlackSection() {
const params = useParams()
const billing = createAsync(() => queryBillingInfo(params.id!))
const subscription = createAsync(() => querySubscription(params.id!))
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
const subscription = createAsync(() => querySubscription(params.id!))
const cancelAction = useAction(cancelWaitlist)
const cancelSubmission = useSubmission(cancelWaitlist)
const enrollAction = useAction(enroll)
const enrollSubmission = useSubmission(enroll)
const [store, setStore] = createStore({
sessionRedirecting: false,
cancelled: false,
enrolled: false,
})
async function onClickSession() {
@@ -92,11 +131,25 @@ export function BlackSection() {
}
}
async function onClickCancel() {
const result = await cancelAction(params.id!)
if (!result.error) {
setStore("cancelled", true)
}
}
async function onClickEnroll() {
const result = await enrollAction(params.id!)
if (!result.error) {
setStore("enrolled", true)
}
}
return (
<section class={styles.root}>
<>
<Show when={subscription()}>
{(sub) => (
<>
<section class={styles.root}>
<div data-slot="section-title">
<h2>Subscription</h2>
<div data-slot="title-row">
@@ -132,9 +185,45 @@ export function BlackSection() {
<span data-slot="reset-time">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
</div>
</div>
</>
</section>
)}
</Show>
</section>
<Show when={billing()?.timeSubscriptionBooked}>
<section class={waitlistStyles.root}>
<div data-slot="section-title">
<h2>Waitlist</h2>
<div data-slot="title-row">
<p>
{billing()?.timeSubscriptionSelected
? `We're ready to enroll you into the $${billing()?.subscriptionPlan} per month OpenCode Black plan.`
: `You are on the waitlist for the $${billing()?.subscriptionPlan} per month OpenCode Black plan.`}
</p>
<button
data-color="danger"
disabled={cancelSubmission.pending || store.cancelled}
onClick={onClickCancel}
>
{cancelSubmission.pending ? "Leaving..." : store.cancelled ? "Left" : "Leave Waitlist"}
</button>
</div>
</div>
<Show when={billing()?.timeSubscriptionSelected}>
<div data-slot="enroll-section">
<button
data-slot="enroll-button"
data-color="primary"
disabled={enrollSubmission.pending || store.enrolled}
onClick={onClickEnroll}
>
{enrollSubmission.pending ? "Enrolling..." : store.enrolled ? "Enrolled" : "Enroll"}
</button>
<p data-slot="enroll-note">
When you click Enroll, your subscription starts immediately and your card will be charged.
</p>
</div>
</Show>
</section>
</Show>
</>
)
}

View File

@@ -5,4 +5,19 @@
align-items: center;
gap: var(--space-4);
}
[data-slot="enroll-section"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-slot="enroll-button"] {
align-self: flex-start;
}
[data-slot="enroll-note"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}

View File

@@ -1,57 +0,0 @@
import { action, useParams, useAction, useSubmission, json, createAsync } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { withActor } from "~/context/auth.withActor"
import { queryBillingInfo } from "../../common"
import styles from "./black-waitlist-section.module.css"
const cancelWaitlist = action(async (workspaceID: string) => {
"use server"
return json(
await withActor(async () => {
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
subscriptionPlan: null,
timeSubscriptionBooked: null,
})
.where(eq(BillingTable.workspaceID, workspaceID)),
)
return { error: undefined }
}, workspaceID).catch((e) => ({ error: e.message as string })),
{ revalidate: queryBillingInfo.key },
)
}, "cancelWaitlist")
export function BlackWaitlistSection() {
const params = useParams()
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const cancelAction = useAction(cancelWaitlist)
const cancelSubmission = useSubmission(cancelWaitlist)
const [store, setStore] = createStore({
cancelled: false,
})
async function onClickCancel() {
const result = await cancelAction(params.id!)
if (!result.error) {
setStore("cancelled", true)
}
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Waitlist</h2>
<div data-slot="title-row">
<p>You are on the waitlist for the ${billingInfo()?.subscriptionPlan} per month OpenCode Black plan.</p>
<button data-color="danger" disabled={cancelSubmission.pending || store.cancelled} onClick={onClickCancel}>
{cancelSubmission.pending ? "Leaving..." : store.cancelled ? "Left" : "Leave Waitlist"}
</button>
</div>
</div>
</section>
)
}

View File

@@ -3,7 +3,6 @@ import { BillingSection } from "./billing-section"
import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
import { BlackWaitlistSection } from "./black-waitlist-section"
import { Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common"
@@ -17,12 +16,9 @@ export default function () {
<div data-page="workspace-[id]">
<div data-slot="sections">
<Show when={sessionInfo()?.isAdmin}>
<Show when={billingInfo()?.subscriptionID}>
<Show when={billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked}>
<BlackSection />
</Show>
<Show when={billingInfo()?.timeSubscriptionBooked}>
<BlackWaitlistSection />
</Show>
<BillingSection />
<Show when={billingInfo()?.customerID}>
<ReloadSection />

View File

@@ -1,4 +1,4 @@
import { Show, createMemo } from "solid-js"
import { Match, Show, Switch, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
import { NewUserSection } from "./new-user-section"
@@ -43,9 +43,8 @@ export default function () {
</span>
<Show when={userInfo()?.isAdmin}>
<span data-slot="billing-info">
<Show
when={billingInfo()?.reload}
fallback={
<Switch>
<Match when={!billingInfo()?.customerID}>
<button
data-color="primary"
data-size="sm"
@@ -54,12 +53,13 @@ export default function () {
>
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable billing"}
</button>
}
>
<span data-slot="balance">
Current balance <b>${balance()}</b>
</span>
</Show>
</Match>
<Match when={!billingInfo()?.subscriptionID}>
<span data-slot="balance">
Current balance <b>${balance()}</b>
</span>
</Match>
</Switch>
</span>
</Show>
</p>

View File

@@ -113,6 +113,7 @@ export const queryBillingInfo = query(async (workspaceID: string) => {
subscriptionID: billing.subscriptionID,
subscriptionPlan: billing.subscriptionPlan,
timeSubscriptionBooked: billing.timeSubscriptionBooked,
timeSubscriptionSelected: billing.timeSubscriptionSelected,
}
}, workspaceID)
}, "billing.get")