wip: zen lite

This commit is contained in:
Frank 2026-02-24 04:45:39 -05:00
commit fb6d201ee0
41 changed files with 4127 additions and 432 deletions

View file

@ -90,7 +90,7 @@ const enroll = action(async (workspaceID: string) => {
"use server"
return json(
await withActor(async () => {
await Billing.subscribe({ seats: 1 })
await Billing.subscribeBlack({ seats: 1 })
return { error: undefined }
}, workspaceID).catch((e) => ({ error: e.message as string })),
{ revalidate: [queryBillingInfo.key, querySubscription.key] },

View file

@ -3,7 +3,8 @@ import { BillingSection } from "./billing-section"
import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
import { Show } from "solid-js"
import { LiteSection } from "./lite-section"
import { createMemo, Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common"
@ -11,14 +12,18 @@ export default function () {
const params = useParams()
const sessionInfo = createAsync(() => querySessionInfo(params.id!))
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const isBlack = createMemo(() => billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked)
return (
<div data-page="workspace-[id]">
<div data-slot="sections">
<Show when={sessionInfo()?.isAdmin}>
<Show when={billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked}>
<Show when={isBlack()}>
<BlackSection />
</Show>
<Show when={!isBlack() && sessionInfo()?.isBeta}>
<LiteSection />
</Show>
<BillingSection />
<Show when={billingInfo()?.customerID}>
<ReloadSection />

View file

@ -0,0 +1,160 @@
.root {
[data-slot="title-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
[data-slot="usage"] {
display: flex;
gap: var(--space-6);
margin-top: var(--space-4);
@media (max-width: 40rem) {
flex-direction: column;
gap: var(--space-4);
}
}
[data-slot="usage-item"] {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-slot="usage-header"] {
display: flex;
justify-content: space-between;
align-items: baseline;
}
[data-slot="usage-label"] {
font-size: var(--font-size-md);
font-weight: 500;
color: var(--color-text);
}
[data-slot="usage-value"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
[data-slot="progress"] {
height: 8px;
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
overflow: hidden;
}
[data-slot="progress-bar"] {
height: 100%;
background-color: var(--color-accent);
border-radius: var(--border-radius-sm);
transition: width 0.3s ease;
}
[data-slot="reset-time"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
[data-slot="setting-row"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-4);
p {
font-size: var(--font-size-sm);
line-height: 1.5;
color: var(--color-text-secondary);
margin: 0;
}
}
[data-slot="toggle-label"] {
position: relative;
display: inline-block;
width: 2.5rem;
height: 1.5rem;
cursor: pointer;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
}
span {
position: absolute;
inset: 0;
background-color: #ccc;
border: 1px solid #bbb;
border-radius: 1.5rem;
transition: all 0.3s ease;
cursor: pointer;
&::before {
content: "";
position: absolute;
top: 50%;
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
background-color: white;
border: 1px solid #ddd;
border-radius: 50%;
transform: translateY(-50%);
transition: all 0.3s ease;
}
}
input:checked + span {
background-color: #21ad0e;
border-color: #148605;
&::before {
transform: translateX(1rem) translateY(-50%);
}
}
&:hover span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
input:checked:hover + span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
}
&:has(input:disabled) {
cursor: not-allowed;
}
input:disabled + span {
opacity: 0.5;
cursor: not-allowed;
}
}
[data-slot="other-message"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
line-height: 1.5;
}
[data-slot="promo-description"] {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: 1.5;
margin-top: var(--space-2);
}
[data-slot="subscribe-button"] {
align-self: flex-start;
margin-top: var(--space-4);
}
}

View file

@ -0,0 +1,269 @@
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { Show } from "solid-js"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { withActor } from "~/context/auth.withActor"
import { queryBillingInfo } from "../../common"
import styles from "./lite-section.module.css"
import { useI18n } from "~/context/i18n"
const queryLiteSubscription = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
const row = await Database.use((tx) =>
tx
.select({
userID: LiteTable.userID,
rollingUsage: LiteTable.rollingUsage,
weeklyUsage: LiteTable.weeklyUsage,
monthlyUsage: LiteTable.monthlyUsage,
timeRollingUpdated: LiteTable.timeRollingUpdated,
timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
timeCreated: LiteTable.timeCreated,
lite: BillingTable.lite,
})
.from(BillingTable)
.innerJoin(LiteTable, eq(LiteTable.workspaceID, BillingTable.workspaceID))
.where(and(eq(LiteTable.workspaceID, Actor.workspace()), isNull(LiteTable.timeDeleted)))
.then((r) => r[0]),
)
if (!row) return null
const limits = LiteData.getLimits()
const mine = row.userID === Actor.userID()
return {
mine,
useBalance: row.lite?.useBalance ?? false,
rollingUsage: Subscription.analyzeRollingUsage({
limit: limits.rollingLimit,
window: limits.rollingWindow,
usage: row.rollingUsage ?? 0,
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
weeklyUsage: Subscription.analyzeWeeklyUsage({
limit: limits.weeklyLimit,
usage: row.weeklyUsage ?? 0,
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
}),
monthlyUsage: Subscription.analyzeMonthlyUsage({
limit: limits.monthlyLimit,
usage: row.monthlyUsage ?? 0,
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
timeSubscribed: row.timeCreated,
}),
}
}, workspaceID)
}, "lite.subscription.get")
function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
return `${days} ${days === 1 ? i18n.t("workspace.lite.time.day") : i18n.t("workspace.lite.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours >= 1)
return `${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
if (minutes === 0) return i18n.t("workspace.lite.time.fewSeconds")
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
}
const createLiteCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
"use server"
return json(
await withActor(
() =>
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
)
}, "liteCheckoutUrl")
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return json(
await withActor(
() =>
Billing.generateSessionUrl({ returnUrl })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
)
}, "liteSessionUrl")
const setLiteUseBalance = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
const useBalance = form.get("useBalance")?.toString() === "true"
return json(
await withActor(async () => {
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
lite: useBalance ? { useBalance: true } : {},
})
.where(eq(BillingTable.workspaceID, workspaceID)),
)
return { error: undefined }
}, workspaceID).catch((e) => ({ error: e.message as string })),
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
)
}, "setLiteUseBalance")
export function LiteSection() {
const params = useParams()
const i18n = useI18n()
const lite = createAsync(() => queryLiteSubscription(params.id!))
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
const checkoutAction = useAction(createLiteCheckoutUrl)
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
const useBalanceSubmission = useSubmission(setLiteUseBalance)
const [store, setStore] = createStore({
redirecting: false,
})
async function onClickSession() {
const result = await sessionAction(params.id!, window.location.href)
if (result.data) {
setStore("redirecting", true)
window.location.href = result.data
}
}
async function onClickSubscribe() {
const result = await checkoutAction(params.id!, window.location.href, window.location.href)
if (result.data) {
setStore("redirecting", true)
window.location.href = result.data
}
}
return (
<>
<Show when={lite() && lite()!.mine && lite()!}>
{(sub) => (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.lite.subscription.title")}</h2>
<div data-slot="title-row">
<p>{i18n.t("workspace.lite.subscription.message")}</p>
<button
data-color="primary"
disabled={sessionSubmission.pending || store.redirecting}
onClick={onClickSession}
>
{sessionSubmission.pending || store.redirecting
? i18n.t("workspace.lite.loading")
: i18n.t("workspace.lite.subscription.manage")}
</button>
</div>
</div>
<div data-slot="usage">
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.rollingUsage")}</span>
<span data-slot="usage-value">{sub().rollingUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().rollingUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
{formatResetTime(sub().rollingUsage.resetInSec, i18n)}
</span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.weeklyUsage")}</span>
<span data-slot="usage-value">{sub().weeklyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().weeklyUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")} {formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
</span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.monthlyUsage")}</span>
<span data-slot="usage-value">{sub().monthlyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().monthlyUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
{formatResetTime(sub().monthlyUsage.resetInSec, i18n)}
</span>
</div>
</div>
<form action={setLiteUseBalance} method="post" data-slot="setting-row">
<p>{i18n.t("workspace.lite.subscription.useBalance")}</p>
<input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="useBalance" value={sub().useBalance ? "false" : "true"} />
<label data-slot="toggle-label">
<input
type="checkbox"
checked={sub().useBalance}
disabled={useBalanceSubmission.pending}
onChange={(e) => e.currentTarget.form?.requestSubmit()}
/>
<span></span>
</label>
</form>
</section>
)}
</Show>
<Show when={lite() && !lite()!.mine}>
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.lite.other.title")}</h2>
</div>
<p data-slot="other-message">{i18n.t("workspace.lite.other.message")}</p>
</section>
</Show>
<Show when={lite() === null}>
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.lite.promo.title")}</h2>
</div>
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.description")}</p>
<button
data-slot="subscribe-button"
data-color="primary"
disabled={checkoutSubmission.pending || store.redirecting}
onClick={onClickSubscribe}
>
{checkoutSubmission.pending || store.redirecting
? i18n.t("workspace.lite.promo.subscribing")
: i18n.t("workspace.lite.promo.subscribe")}
</button>
</section>
</Show>
</>
)
}

View file

@ -36,7 +36,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
model: UsageTable.model,
totalCost: sum(UsageTable.cost),
keyId: UsageTable.keyID,
subscription: sql<boolean>`COALESCE(JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') = 'sub', false)`,
plan: sql<string | null>`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`,
})
.from(UsageTable)
.where(
@ -50,13 +50,13 @@ async function getCosts(workspaceID: string, year: number, month: number) {
sql`DATE(${UsageTable.timeCreated})`,
UsageTable.model,
UsageTable.keyID,
sql`COALESCE(JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') = 'sub', false)`,
sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`,
)
.then((x) =>
x.map((r) => ({
...r,
totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
subscription: Boolean(r.subscription),
plan: r.plan as "sub" | "lite" | "byok" | null,
})),
),
)
@ -218,18 +218,21 @@ export function GraphSection() {
const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
const colorBorder = styles.getPropertyValue("--color-border").trim()
const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})`
const liteSuffix = ` (${i18n.t("workspace.cost.liteShort")})`
const dailyDataRegular = new Map<string, Map<string, number>>()
const dailyDataSub = new Map<string, Map<string, number>>()
const dailyDataNonSub = new Map<string, Map<string, number>>()
const dailyDataLite = new Map<string, Map<string, number>>()
for (const dateKey of dates) {
dailyDataRegular.set(dateKey, new Map())
dailyDataSub.set(dateKey, new Map())
dailyDataNonSub.set(dateKey, new Map())
dailyDataLite.set(dateKey, new Map())
}
data.usage
.filter((row) => (store.key ? row.keyId === store.key : true))
.forEach((row) => {
const targetMap = row.subscription ? dailyDataSub : dailyDataNonSub
const targetMap = row.plan === "sub" ? dailyDataSub : row.plan === "lite" ? dailyDataLite : dailyDataRegular
const dayMap = targetMap.get(row.date)
if (!dayMap) return
dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
@ -237,15 +240,15 @@ export function GraphSection() {
const filteredModels = store.model === null ? getModels() : [store.model]
// Create datasets: non-subscription first, then subscription (with hatched pattern effect via opacity)
// Create datasets: regular first, then subscription, then lite (with visual distinction via opacity)
const datasets = [
...filteredModels
.filter((model) => dates.some((date) => (dailyDataNonSub.get(date)?.get(model) || 0) > 0))
.filter((model) => dates.some((date) => (dailyDataRegular.get(date)?.get(model) || 0) > 0))
.map((model) => {
const color = getModelColor(model)
return {
label: model,
data: dates.map((date) => (dailyDataNonSub.get(date)?.get(model) || 0) / 100_000_000),
data: dates.map((date) => (dailyDataRegular.get(date)?.get(model) || 0) / 100_000_000),
backgroundColor: color,
hoverBackgroundColor: color,
borderWidth: 0,
@ -266,6 +269,21 @@ export function GraphSection() {
stack: "subscription",
}
}),
...filteredModels
.filter((model) => dates.some((date) => (dailyDataLite.get(date)?.get(model) || 0) > 0))
.map((model) => {
const color = getModelColor(model)
return {
label: `${model}${liteSuffix}`,
data: dates.map((date) => (dailyDataLite.get(date)?.get(model) || 0) / 100_000_000),
backgroundColor: addOpacityToColor(color, 0.35),
hoverBackgroundColor: addOpacityToColor(color, 0.55),
borderWidth: 1,
borderColor: addOpacityToColor(color, 0.7),
borderDash: [4, 2],
stack: "lite",
}
}),
]
return {
@ -347,9 +365,18 @@ export function GraphSection() {
const meta = chart.getDatasetMeta(i)
const label = dataset.label || ""
const isSub = label.endsWith(subSuffix)
const model = isSub ? label.slice(0, -subSuffix.length) : label
const isLite = label.endsWith(liteSuffix)
const model = isSub
? label.slice(0, -subSuffix.length)
: isLite
? label.slice(0, -liteSuffix.length)
: label
const baseColor = getModelColor(model)
const originalColor = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
const originalColor = isSub
? addOpacityToColor(baseColor, 0.5)
: isLite
? addOpacityToColor(baseColor, 0.35)
: baseColor
const color = i === legendItem.datasetIndex ? originalColor : addOpacityToColor(baseColor, 0.15)
meta.data.forEach((bar: any) => {
bar.options.backgroundColor = color
@ -363,9 +390,18 @@ export function GraphSection() {
const meta = chart.getDatasetMeta(i)
const label = dataset.label || ""
const isSub = label.endsWith(subSuffix)
const model = isSub ? label.slice(0, -subSuffix.length) : label
const isLite = label.endsWith(liteSuffix)
const model = isSub
? label.slice(0, -subSuffix.length)
: isLite
? label.slice(0, -liteSuffix.length)
: label
const baseColor = getModelColor(model)
const color = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
const color = isSub
? addOpacityToColor(baseColor, 0.5)
: isLite
? addOpacityToColor(baseColor, 0.35)
: baseColor
meta.data.forEach((bar: any) => {
bar.options.backgroundColor = color
})

View file

@ -1,6 +1,6 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, For, Show, createEffect, createSignal } from "solid-js"
import { createMemo, For, Show, Switch, Match, createEffect, createSignal } from "solid-js"
import { formatDateUTC, formatDateForTable } from "../common"
import { withActor } from "~/context/auth.withActor"
import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
@ -175,23 +175,23 @@ export function UsageSection() {
</div>
</td>
<td data-slot="usage-cost">
<Show
when={usage.enrichment?.plan === "sub"}
fallback={
<Show
when={usage.enrichment?.plan === "byok"}
fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}
>
{i18n.t("workspace.usage.byok", {
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
})}
</Show>
}
>
{i18n.t("workspace.usage.subscription", {
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
})}
</Show>
<Switch fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}>
<Match when={usage.enrichment?.plan === "sub"}>
{i18n.t("workspace.usage.subscription", {
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
})}
</Match>
<Match when={usage.enrichment?.plan === "lite"}>
{i18n.t("workspace.usage.lite", {
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
})}
</Match>
<Match when={usage.enrichment?.plan === "byok"}>
{i18n.t("workspace.usage.byok", {
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
})}
</Match>
</Switch>
</td>
<td data-slot="usage-session">{usage.sessionID?.slice(-8) ?? "-"}</td>
</tr>

View file

@ -115,6 +115,8 @@ export const queryBillingInfo = query(async (workspaceID: string) => {
subscriptionPlan: billing.subscriptionPlan,
timeSubscriptionBooked: billing.timeSubscriptionBooked,
timeSubscriptionSelected: billing.timeSubscriptionSelected,
lite: billing.lite,
liteSubscriptionID: billing.liteSubscriptionID,
}
}, workspaceID)
}, "billing.get")