chore: initialize qiming workspace repository
This commit is contained in:
337
qimingcode/packages/console/app/src/routes/workspace/[id].css
Normal file
337
qimingcode/packages/console/app/src/routes/workspace/[id].css
Normal file
@@ -0,0 +1,337 @@
|
||||
[data-page="workspace"] {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Workspace Layout */
|
||||
[data-component="workspace-container"] {
|
||||
display: flex;
|
||||
height: calc(100vh - 73px);
|
||||
}
|
||||
|
||||
[data-component="workspace-nav"] {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-6) var(--space-4);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Desktop Navigation */
|
||||
[data-component="nav-desktop"] {
|
||||
display: block;
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-component="workspace-nav-items"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
[data-nav-button] {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(-1 * var(--space-0-5));
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: var(--color-text);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Navigation */
|
||||
[data-component="nav-mobile"] {
|
||||
display: none;
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="workspace-nav-items"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
min-width: max-content;
|
||||
height: 100%;
|
||||
|
||||
[data-nav-button] {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
padding-bottom: calc(var(--space-2) + 4px);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: var(--color-text);
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="workspace-content"] {
|
||||
flex: 1;
|
||||
padding: var(--space-6) var(--space-8);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="workspace-main"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-component="workspace-content"] > [data-component="legal"] {
|
||||
margin-top: var(--space-16);
|
||||
padding-top: var(--space-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
color: var(--color-text-weak);
|
||||
display: flex;
|
||||
gap: var(--space-8);
|
||||
|
||||
a {
|
||||
color: var(--color-text-weak);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="workspace-[id]"] {
|
||||
max-width: 64rem;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
padding-top: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
[data-slot="sections"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-16);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
[data-slot="section-title"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.03125rem;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-muted);
|
||||
|
||||
a {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="section-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
section:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: var(--space-16);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
padding-bottom: var(--space-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Title section */
|
||||
[data-component="header-section"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding-bottom: var(--space-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
padding-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.03125rem;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="billing-info"] {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
[data-slot="balance"] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
|
||||
b {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
[data-component="workspace-container"] {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-component="workspace-nav"] {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: var(--space-4);
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex-shrink: 0;
|
||||
min-height: fit-content;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Show } from "solid-js"
|
||||
import { createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
|
||||
import { querySessionInfo } from "./common"
|
||||
import "./[id].css"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { Legal } from "~/component/legal"
|
||||
|
||||
export default function WorkspaceLayout(props: RouteSectionProps) {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const userInfo = createAsync(() => querySessionInfo(params.id!))
|
||||
|
||||
return (
|
||||
<main data-page="workspace">
|
||||
<div data-component="workspace-container">
|
||||
<nav data-component="workspace-nav">
|
||||
<nav data-component="nav-desktop">
|
||||
<div data-component="workspace-nav-items">
|
||||
<A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
|
||||
{i18n.t("workspace.nav.zen")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/go`} activeClass="active" data-nav-button>
|
||||
{i18n.t("workspace.nav.go")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/usage`} activeClass="active" data-nav-button>
|
||||
{i18n.t("workspace.nav.usage")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
|
||||
{i18n.t("workspace.nav.apiKeys")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
|
||||
{i18n.t("workspace.nav.members")}
|
||||
</A>
|
||||
<Show when={userInfo()?.isAdmin}>
|
||||
<A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
|
||||
{i18n.t("workspace.nav.billing")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
|
||||
{i18n.t("workspace.nav.settings")}
|
||||
</A>
|
||||
</Show>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<nav data-component="nav-mobile">
|
||||
<div data-component="workspace-nav-items">
|
||||
<A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
|
||||
{i18n.t("workspace.nav.zen")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/go`} activeClass="active" data-nav-button>
|
||||
{i18n.t("workspace.nav.go")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/usage`} activeClass="active" data-nav-button>
|
||||
{i18n.t("workspace.nav.usage")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
|
||||
{i18n.t("workspace.nav.apiKeys")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
|
||||
{i18n.t("workspace.nav.members")}
|
||||
</A>
|
||||
<Show when={userInfo()?.isAdmin}>
|
||||
<A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
|
||||
{i18n.t("workspace.nav.billing")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
|
||||
{i18n.t("workspace.nav.settings")}
|
||||
</A>
|
||||
</Show>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
<div data-component="workspace-content">
|
||||
<div data-component="workspace-main">{props.children}</div>
|
||||
<Legal />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
.root {
|
||||
[data-slot="reload-error"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
|
||||
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="section-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
[data-slot="balance-display"] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
[data-slot="balance-amount"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg-surface);
|
||||
align-self: stretch;
|
||||
|
||||
[data-slot="balance-label"] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin-top: var(--space-2);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
[data-slot="balance-value"] {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="balance-right-section"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-slot="add-balance-form-container"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
[data-slot="add-balance-form"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
input[data-component="input"] {
|
||||
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);
|
||||
line-height: 1.5;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-alpha);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="form-actions"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="form-error"] {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
[data-slot="credit-card"] {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background-color: var(--color-bg-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 150px;
|
||||
align-self: flex-start;
|
||||
|
||||
[data-slot="card-icon"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="card-details"] {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1);
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
|
||||
[data-slot="secret"] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
[data-slot="number"] {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="type"] {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
align-self: flex-start;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="enable-billing-button"] {
|
||||
align-self: flex-start;
|
||||
padding: var(--space-4);
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { action, useParams, useAction, createAsync, useSubmission, json } from "@solidjs/router"
|
||||
import { createMemo, Match, Show, Switch, createEffect } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { IconAlipay, IconCreditCard, IconStripe, IconUpi, IconWechat } from "~/component/icon"
|
||||
import styles from "./billing-section.module.css"
|
||||
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { localizeError } from "~/lib/form-error"
|
||||
|
||||
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 },
|
||||
)
|
||||
}, "sessionUrl")
|
||||
|
||||
export function BillingSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
|
||||
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
|
||||
const checkoutAction = useAction(createCheckoutUrl)
|
||||
const checkoutSubmission = useSubmission(createCheckoutUrl)
|
||||
const sessionAction = useAction(createSessionUrl)
|
||||
const sessionSubmission = useSubmission(createSessionUrl)
|
||||
const [store, setStore] = createStore({
|
||||
showAddBalanceForm: false,
|
||||
addBalanceAmount: billingInfo()?.reloadAmount.toString() ?? "",
|
||||
checkoutRedirecting: false,
|
||||
sessionRedirecting: false,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const info = billingInfo()
|
||||
if (info) {
|
||||
setStore("addBalanceAmount", info.reloadAmount.toString())
|
||||
}
|
||||
})
|
||||
const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0))
|
||||
|
||||
async function onClickCheckout() {
|
||||
const amount = parseInt(store.addBalanceAmount)
|
||||
const baseUrl = window.location.href
|
||||
|
||||
const checkout = await checkoutAction(params.id!, amount, baseUrl, baseUrl)
|
||||
if (checkout && checkout.data) {
|
||||
setStore("checkoutRedirecting", true)
|
||||
window.location.href = checkout.data
|
||||
}
|
||||
}
|
||||
|
||||
async function onClickSession() {
|
||||
const baseUrl = window.location.href
|
||||
const sessionUrl = await sessionAction(params.id!, baseUrl)
|
||||
if (sessionUrl && sessionUrl.data) {
|
||||
setStore("sessionRedirecting", true)
|
||||
window.location.href = sessionUrl.data
|
||||
}
|
||||
}
|
||||
|
||||
function showAddBalanceForm() {
|
||||
while (true) {
|
||||
checkoutSubmission.clear()
|
||||
if (!checkoutSubmission.result) break
|
||||
}
|
||||
setStore({
|
||||
showAddBalanceForm: true,
|
||||
})
|
||||
}
|
||||
|
||||
function hideAddBalanceForm() {
|
||||
setStore("showAddBalanceForm", false)
|
||||
checkoutSubmission.clear()
|
||||
}
|
||||
|
||||
// DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
|
||||
|
||||
// Scenario 1: User has not added billing details and has no balance
|
||||
// const balanceInfo = () => ({
|
||||
// balance: 0,
|
||||
// paymentMethodType: null as string | null,
|
||||
// paymentMethodLast4: null as string | null,
|
||||
// reload: false,
|
||||
// reloadError: null as string | null,
|
||||
// timeReloadError: null as Date | null,
|
||||
// })
|
||||
|
||||
// Scenario 2: User has not added billing details but has a balance
|
||||
// const balanceInfo = () => ({
|
||||
// balance: 1500000000, // $15.00
|
||||
// paymentMethodType: null as string | null,
|
||||
// paymentMethodLast4: null as string | null,
|
||||
// reload: false,
|
||||
// reloadError: null as string | null,
|
||||
// timeReloadError: null as Date | null
|
||||
// })
|
||||
|
||||
// Scenario 3: User has added billing details (reload enabled)
|
||||
// const balanceInfo = () => ({
|
||||
// balance: 750000000, // $7.50
|
||||
// paymentMethodType: "card",
|
||||
// paymentMethodLast4: "4242",
|
||||
// reload: true,
|
||||
// reloadError: null as string | null,
|
||||
// timeReloadError: null as Date | null
|
||||
// })
|
||||
|
||||
// Scenario 4: User has billing details but reload failed
|
||||
// const balanceInfo = () => ({
|
||||
// balance: 250000000, // $2.50
|
||||
// paymentMethodType: "card",
|
||||
// paymentMethodLast4: "4242",
|
||||
// reload: true,
|
||||
// reloadError: "Your card was declined." as string,
|
||||
// timeReloadError: new Date(Date.now() - 3600000) as Date // 1 hour ago
|
||||
// })
|
||||
|
||||
// Scenario 5: User has Link payment method
|
||||
// const balanceInfo = () => ({
|
||||
// balance: 500000000, // $5.00
|
||||
// paymentMethodType: "link",
|
||||
// paymentMethodLast4: null as string | null,
|
||||
// reload: true,
|
||||
// reloadError: null as string | null,
|
||||
// timeReloadError: null as Date | null
|
||||
// })
|
||||
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.billing.title")}</h2>
|
||||
<p>
|
||||
{i18n.t("workspace.billing.subtitle.beforeLink")}{" "}
|
||||
<a href="mailto:contact@anoma.ly">{i18n.t("workspace.billing.contactUs")}</a>{" "}
|
||||
{i18n.t("workspace.billing.subtitle.afterLink")}
|
||||
</p>
|
||||
</div>
|
||||
<div data-slot="section-content">
|
||||
<div data-slot="balance-display">
|
||||
<div data-slot="balance-amount">
|
||||
<span data-slot="balance-value">${balance()}</span>
|
||||
<span data-slot="balance-label">{i18n.t("workspace.billing.currentBalance")}</span>
|
||||
</div>
|
||||
<Show when={billingInfo()?.customerID}>
|
||||
<div data-slot="balance-right-section">
|
||||
<Show
|
||||
when={!store.showAddBalanceForm}
|
||||
fallback={
|
||||
<div data-slot="add-balance-form-container">
|
||||
<div data-slot="add-balance-form">
|
||||
<label>{i18n.t("workspace.billing.add")}</label>
|
||||
<input
|
||||
data-component="input"
|
||||
type="number"
|
||||
min={billingInfo()?.reloadAmountMin.toString()}
|
||||
step="1"
|
||||
value={store.addBalanceAmount}
|
||||
onInput={(e) => {
|
||||
setStore("addBalanceAmount", e.currentTarget.value)
|
||||
checkoutSubmission.clear()
|
||||
}}
|
||||
placeholder={i18n.t("workspace.billing.enterAmount")}
|
||||
/>
|
||||
<div data-slot="form-actions">
|
||||
<button data-color="ghost" type="button" onClick={() => hideAddBalanceForm()}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
data-color="primary"
|
||||
type="button"
|
||||
disabled={!store.addBalanceAmount || checkoutSubmission.pending || store.checkoutRedirecting}
|
||||
onClick={onClickCheckout}
|
||||
>
|
||||
{checkoutSubmission.pending || store.checkoutRedirecting
|
||||
? i18n.t("workspace.billing.loading")
|
||||
: i18n.t("workspace.billing.addAction")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={checkoutSubmission.result && (checkoutSubmission.result as any).error}>
|
||||
{(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button data-color="primary" onClick={() => showAddBalanceForm()}>
|
||||
{i18n.t("workspace.billing.addBalance")}
|
||||
</button>
|
||||
</Show>
|
||||
<div data-slot="credit-card">
|
||||
<div data-slot="card-icon">
|
||||
<Switch fallback={<IconCreditCard style={{ width: "24px", height: "24px" }} />}>
|
||||
<Match when={billingInfo()?.paymentMethodType === "link"}>
|
||||
<IconStripe style={{ width: "24px", height: "24px" }} />
|
||||
</Match>
|
||||
<Match when={billingInfo()?.paymentMethodType === "alipay"}>
|
||||
<IconAlipay style={{ width: "24px", height: "24px" }} />
|
||||
</Match>
|
||||
<Match when={billingInfo()?.paymentMethodType === "wechat_pay"}>
|
||||
<IconWechat style={{ width: "24px", height: "24px" }} />
|
||||
</Match>
|
||||
<Match when={billingInfo()?.paymentMethodType === "upi"}>
|
||||
<IconUpi style={{ width: "auto", height: "16px" }} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div data-slot="card-details">
|
||||
<Switch>
|
||||
<Match when={billingInfo()?.paymentMethodType === "card"}>
|
||||
<Show when={billingInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
|
||||
<span data-slot="secret">••••</span>
|
||||
<span data-slot="number">{billingInfo()?.paymentMethodLast4}</span>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={billingInfo()?.paymentMethodType === "link"}>
|
||||
<span data-slot="type">{i18n.t("workspace.billing.linkedToStripe")}</span>
|
||||
</Match>
|
||||
<Match when={billingInfo()?.paymentMethodType === "alipay"}>
|
||||
<span data-slot="type">{i18n.t("workspace.billing.alipay")}</span>
|
||||
</Match>
|
||||
<Match when={billingInfo()?.paymentMethodType === "wechat_pay"}>
|
||||
<span data-slot="type">{i18n.t("workspace.billing.wechat")}</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<button
|
||||
data-color="ghost"
|
||||
disabled={sessionSubmission.pending || store.sessionRedirecting}
|
||||
onClick={onClickSession}
|
||||
>
|
||||
{sessionSubmission.pending || store.sessionRedirecting
|
||||
? i18n.t("workspace.billing.loading")
|
||||
: i18n.t("workspace.billing.manage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!billingInfo()?.customerID}>
|
||||
<button
|
||||
data-slot="enable-billing-button"
|
||||
data-color="primary"
|
||||
disabled={checkoutSubmission.pending || store.checkoutRedirecting}
|
||||
onClick={onClickCheckout}
|
||||
>
|
||||
{checkoutSubmission.pending || store.checkoutRedirecting
|
||||
? i18n.t("workspace.billing.loading")
|
||||
: i18n.t("workspace.billing.enable")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
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, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BillingTable, SubscriptionTable } 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 { BlackData } 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"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { formError } from "~/lib/form-error"
|
||||
|
||||
const querySubscription = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
const row = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
rollingUsage: SubscriptionTable.rollingUsage,
|
||||
fixedUsage: SubscriptionTable.fixedUsage,
|
||||
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
|
||||
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
|
||||
subscription: BillingTable.subscription,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.innerJoin(SubscriptionTable, eq(SubscriptionTable.workspaceID, BillingTable.workspaceID))
|
||||
.where(and(eq(SubscriptionTable.workspaceID, Actor.workspace()), isNull(SubscriptionTable.timeDeleted)))
|
||||
.then((r) => r[0]),
|
||||
)
|
||||
if (!row?.subscription) return null
|
||||
const blackData = BlackData.getLimits({ plan: row.subscription.plan })
|
||||
|
||||
return {
|
||||
plan: row.subscription.plan,
|
||||
useBalance: row.subscription.useBalance ?? false,
|
||||
rollingUsage: Subscription.analyzeRollingUsage({
|
||||
limit: blackData.rollingLimit,
|
||||
window: blackData.rollingWindow,
|
||||
usage: row.rollingUsage ?? 0,
|
||||
timeUpdated: row.timeRollingUpdated ?? new Date(),
|
||||
}),
|
||||
weeklyUsage: Subscription.analyzeWeeklyUsage({
|
||||
limit: blackData.fixedLimit,
|
||||
usage: row.fixedUsage ?? 0,
|
||||
timeUpdated: row.timeFixedUpdated ?? new Date(),
|
||||
}),
|
||||
}
|
||||
}, workspaceID)
|
||||
}, "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.black.time.day") : i18n.t("workspace.black.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.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.black.time.hour") : i18n.t("workspace.black.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
|
||||
if (minutes === 0) return i18n.t("workspace.black.time.fewSeconds")
|
||||
return `${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.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.subscribeBlack({ 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(
|
||||
await withActor(
|
||||
() =>
|
||||
Billing.generateSessionUrl({ returnUrl })
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({
|
||||
error: e.message as string,
|
||||
data: undefined,
|
||||
})),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
|
||||
)
|
||||
}, "sessionUrl")
|
||||
|
||||
const setUseBalance = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const useBalance = (form.get("useBalance") as string | null) === "true"
|
||||
|
||||
return json(
|
||||
await withActor(async () => {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
subscription: useBalance
|
||||
? sql`JSON_SET(subscription, '$.useBalance', true)`
|
||||
: sql`JSON_REMOVE(subscription, '$.useBalance')`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID)),
|
||||
)
|
||||
return { error: undefined }
|
||||
}, workspaceID).catch((e) => ({ error: e.message as string })),
|
||||
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
|
||||
)
|
||||
}, "setUseBalance")
|
||||
|
||||
export function BlackSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const billing = createAsync(() => queryBillingInfo(params.id!))
|
||||
const subscription = createAsync(() => querySubscription(params.id!))
|
||||
const sessionAction = useAction(createSessionUrl)
|
||||
const sessionSubmission = useSubmission(createSessionUrl)
|
||||
const cancelAction = useAction(cancelWaitlist)
|
||||
const cancelSubmission = useSubmission(cancelWaitlist)
|
||||
const enrollAction = useAction(enroll)
|
||||
const enrollSubmission = useSubmission(enroll)
|
||||
const useBalanceSubmission = useSubmission(setUseBalance)
|
||||
const [store, setStore] = createStore({
|
||||
sessionRedirecting: false,
|
||||
cancelled: false,
|
||||
enrolled: false,
|
||||
})
|
||||
|
||||
async function onClickSession() {
|
||||
const result = await sessionAction(params.id!, window.location.href)
|
||||
if (result.data) {
|
||||
setStore("sessionRedirecting", true)
|
||||
window.location.href = result.data
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Show when={subscription()}>
|
||||
{(sub) => (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.black.subscription.title")}</h2>
|
||||
<div data-slot="title-row">
|
||||
<p>{i18n.t("workspace.black.subscription.message", { plan: sub().plan })}</p>
|
||||
<button
|
||||
data-color="primary"
|
||||
disabled={sessionSubmission.pending || store.sessionRedirecting}
|
||||
onClick={onClickSession}
|
||||
>
|
||||
{sessionSubmission.pending || store.sessionRedirecting
|
||||
? i18n.t("workspace.black.loading")
|
||||
: i18n.t("workspace.black.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.black.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.black.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.black.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.black.subscription.resetsIn")}{" "}
|
||||
{formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<form action={setUseBalance} method="post" data-slot="setting-row">
|
||||
<p>{i18n.t("workspace.black.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={billing()?.timeSubscriptionBooked}>
|
||||
<section class={waitlistStyles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.black.waitlist.title")}</h2>
|
||||
<div data-slot="title-row">
|
||||
<p>
|
||||
{billing()?.timeSubscriptionSelected
|
||||
? i18n.t("workspace.black.waitlist.ready", { plan: billing()?.subscriptionPlan ?? "" })
|
||||
: i18n.t("workspace.black.waitlist.joined", { plan: billing()?.subscriptionPlan ?? "" })}
|
||||
</p>
|
||||
<button
|
||||
data-color="danger"
|
||||
disabled={cancelSubmission.pending || store.cancelled}
|
||||
onClick={onClickCancel}
|
||||
>
|
||||
{cancelSubmission.pending
|
||||
? i18n.t("workspace.black.waitlist.leaving")
|
||||
: store.cancelled
|
||||
? i18n.t("workspace.black.waitlist.left")
|
||||
: i18n.t("workspace.black.waitlist.leave")}
|
||||
</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
|
||||
? i18n.t("workspace.black.waitlist.enrolling")
|
||||
: store.enrolled
|
||||
? i18n.t("workspace.black.waitlist.enrolled")
|
||||
: i18n.t("workspace.black.waitlist.enroll")}
|
||||
</button>
|
||||
<p data-slot="enroll-note">{i18n.t("workspace.black.waitlist.enrollNote")}</p>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.root {
|
||||
[data-slot="title-row"] {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MonthlyLimitSection } from "./monthly-limit-section"
|
||||
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"
|
||||
|
||||
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={isBlack()}>
|
||||
<BlackSection />
|
||||
</Show>
|
||||
<BillingSection />
|
||||
<RedeemSection />
|
||||
<Show when={billingInfo()?.customerID}>
|
||||
<ReloadSection />
|
||||
<MonthlyLimitSection />
|
||||
<PaymentSection />
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
.root {
|
||||
[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;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[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;
|
||||
bottom: 2px;
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
[data-slot="value"] {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 500;
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
||||
import { createEffect, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import styles from "./monthly-limit-section.module.css"
|
||||
import { queryBillingInfo } from "../../common"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { formError, localizeError } from "~/lib/form-error"
|
||||
|
||||
const setMonthlyLimit = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const limit = form.get("limit") as string | null
|
||||
if (!limit) return { error: formError.limitRequired }
|
||||
const numericLimit = parseInt(limit)
|
||||
if (numericLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Billing.setMonthlyLimit(numericLimit)
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({ error: e.message as string })),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: queryBillingInfo.key },
|
||||
)
|
||||
}, "billing.setMonthlyLimit")
|
||||
|
||||
export function MonthlyLimitSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const submission = useSubmission(setMonthlyLimit)
|
||||
const [store, setStore] = createStore({ show: false })
|
||||
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
|
||||
|
||||
let input: HTMLInputElement
|
||||
|
||||
createEffect(() => {
|
||||
if (!submission.pending && submission.result && !submission.result.error) {
|
||||
hide()
|
||||
}
|
||||
})
|
||||
|
||||
function show() {
|
||||
// submission.clear() does not clear the result in some cases, ie.
|
||||
// 1. Create key with empty name => error shows
|
||||
// 2. Put in a key name and creates the key => form hides
|
||||
// 3. Click add key button again => form shows with the same error if
|
||||
// submission.clear() is called only once
|
||||
while (true) {
|
||||
submission.clear()
|
||||
if (!submission.result) break
|
||||
}
|
||||
setStore("show", true)
|
||||
input.focus()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setStore("show", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.monthlyLimit.title")}</h2>
|
||||
<p>{i18n.t("workspace.monthlyLimit.subtitle")}</p>
|
||||
</div>
|
||||
<div data-slot="section-content">
|
||||
<div data-slot="balance">
|
||||
<div data-slot="amount">
|
||||
{billingInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
|
||||
<span data-slot="value">{billingInfo()?.monthlyLimit ?? "-"}</span>
|
||||
</div>
|
||||
<Show
|
||||
when={!store.show}
|
||||
fallback={
|
||||
<form action={setMonthlyLimit} method="post" data-slot="create-form">
|
||||
<div data-slot="input-container">
|
||||
<input
|
||||
required
|
||||
ref={(r) => (input = r)}
|
||||
data-component="input"
|
||||
name="limit"
|
||||
type="number"
|
||||
placeholder={i18n.t("workspace.monthlyLimit.placeholder")}
|
||||
/>
|
||||
<Show when={submission.result && submission.result.error}>
|
||||
{(err) => <div data-slot="form-error">{localizeError(i18n.t, 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()}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||
{submission.pending
|
||||
? i18n.t("workspace.monthlyLimit.setting")
|
||||
: i18n.t("workspace.monthlyLimit.set")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
>
|
||||
<button data-color="primary" onClick={() => show()}>
|
||||
{billingInfo()?.monthlyLimit
|
||||
? i18n.t("workspace.monthlyLimit.edit")
|
||||
: i18n.t("workspace.monthlyLimit.set")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={billingInfo()?.monthlyLimit}
|
||||
fallback={<p data-slot="usage-status">{i18n.t("workspace.monthlyLimit.noLimit")}</p>}
|
||||
>
|
||||
<p data-slot="usage-status">
|
||||
{i18n.t("workspace.monthlyLimit.currentUsage.beforeMonth")}{" "}
|
||||
{new Date().toLocaleDateString(undefined, { month: "long", timeZone: "UTC" })}{" "}
|
||||
{i18n.t("workspace.monthlyLimit.currentUsage.beforeAmount")}
|
||||
{(() => {
|
||||
const dateLastUsed = billingInfo()?.timeMonthlyUsageUpdated
|
||||
if (!dateLastUsed) return "0"
|
||||
|
||||
const current = new Date().toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
const lastUsed = dateLastUsed.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
if (current !== lastUsed) return "0"
|
||||
return ((billingInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
|
||||
})()}
|
||||
.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
.root {
|
||||
[data-slot="payments-table"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
[data-slot="payments-table-element"] {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
thead {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&[data-slot="payment-date"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&[data-slot="payment-id"] {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
max-width: 200px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&[data-slot="payment-amount"] {
|
||||
color: var(--color-text);
|
||||
|
||||
&[data-refunded="true"] {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-slot="payment-receipt"] {
|
||||
span {
|
||||
display: inline-block;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
th {
|
||||
&:nth-child(2)
|
||||
|
||||
/* Payment ID */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
&:nth-child(2)
|
||||
|
||||
/* Payment ID */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
|
||||
import { For, Match, Show, Switch } from "solid-js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { formatDateUTC, formatDateForTable } from "../../common"
|
||||
import styles from "./payment-section.module.css"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
|
||||
function money(amount: number, currency?: string) {
|
||||
const formatter =
|
||||
currency === "inr"
|
||||
? new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" })
|
||||
: new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
|
||||
return formatter.format(amount / 100_000_000)
|
||||
}
|
||||
|
||||
const getPaymentsInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
return await Billing.payments()
|
||||
}, workspaceID)
|
||||
}, "payment.list")
|
||||
|
||||
const downloadReceipt = action(async (workspaceID: string, paymentID: string) => {
|
||||
"use server"
|
||||
return withActor(() => Billing.generateReceiptUrl({ paymentID }), workspaceID)
|
||||
}, "receipt.download")
|
||||
|
||||
export function PaymentSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const payments = createAsync(() => getPaymentsInfo(params.id!))
|
||||
const downloadReceiptAction = useAction(downloadReceipt)
|
||||
|
||||
// DUMMY DATA FOR TESTING
|
||||
// const payments = () => [
|
||||
// {
|
||||
// id: "pi_3QK1x2FT9vXn4A6r1234567890",
|
||||
// paymentID: "pi_3QK1x2FT9vXn4A6r1234567890",
|
||||
// timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // 1 day ago
|
||||
// amount: 2100000000, // $21.00 ($20 + $1 fee)
|
||||
// },
|
||||
// {
|
||||
// id: "pi_3QJ8k7FT9vXn4A6r0987654321",
|
||||
// paymentID: "pi_3QJ8k7FT9vXn4A6r0987654321",
|
||||
// timeCreated: new Date(Date.now() - 86400000 * 15).toISOString(), // 15 days ago
|
||||
// amount: 2100000000, // $21.00
|
||||
// },
|
||||
// {
|
||||
// id: "pi_3QI5m1FT9vXn4A6r5678901234",
|
||||
// paymentID: "pi_3QI5m1FT9vXn4A6r5678901234",
|
||||
// timeCreated: new Date(Date.now() - 86400000 * 32).toISOString(), // 32 days ago
|
||||
// amount: 2100000000, // $21.00
|
||||
// },
|
||||
// {
|
||||
// id: "pi_3QH2n9FT9vXn4A6r3456789012",
|
||||
// paymentID: "pi_3QH2n9FT9vXn4A6r3456789012",
|
||||
// timeCreated: new Date(Date.now() - 86400000 * 47).toISOString(), // 47 days ago
|
||||
// amount: 2100000000, // $21.00
|
||||
// },
|
||||
// {
|
||||
// id: "pi_3QG7p4FT9vXn4A6r7890123456",
|
||||
// paymentID: "pi_3QG7p4FT9vXn4A6r7890123456",
|
||||
// timeCreated: new Date(Date.now() - 86400000 * 63).toISOString(), // 63 days ago
|
||||
// amount: 2100000000, // $21.00
|
||||
// },
|
||||
// ]
|
||||
|
||||
return (
|
||||
<Show when={payments() && payments()!.length > 0}>
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.payments.title")}</h2>
|
||||
<p>{i18n.t("workspace.payments.subtitle")}</p>
|
||||
</div>
|
||||
<div data-slot="payments-table">
|
||||
<table data-slot="payments-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{i18n.t("workspace.payments.table.date")}</th>
|
||||
<th>{i18n.t("workspace.payments.table.paymentId")}</th>
|
||||
<th>{i18n.t("workspace.payments.table.amount")}</th>
|
||||
<th>{i18n.t("workspace.payments.table.receipt")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={payments()!}>
|
||||
{(payment) => {
|
||||
const date = new Date(payment.timeCreated)
|
||||
const amount =
|
||||
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
|
||||
const currency =
|
||||
payment.enrichment?.type === "subscription" || payment.enrichment?.type === "lite"
|
||||
? payment.enrichment.currency
|
||||
: undefined
|
||||
return (
|
||||
<tr>
|
||||
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
||||
{formatDateForTable(date)}
|
||||
</td>
|
||||
<td data-slot="payment-id">{payment.id}</td>
|
||||
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
|
||||
{money(amount, currency)}
|
||||
<Switch>
|
||||
<Match when={payment.enrichment?.type === "credit"}>
|
||||
{" "}
|
||||
({i18n.t("workspace.payments.type.credit")})
|
||||
</Match>
|
||||
<Match when={payment.enrichment?.type === "subscription"}>
|
||||
({i18n.t("workspace.payments.type.subscription")})
|
||||
</Match>
|
||||
</Switch>
|
||||
</td>
|
||||
<td data-slot="payment-receipt">
|
||||
{payment.paymentID ? (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
|
||||
if (receiptUrl) {
|
||||
window.open(receiptUrl, "_blank")
|
||||
}
|
||||
}}
|
||||
data-slot="receipt-button"
|
||||
>
|
||||
{i18n.t("workspace.payments.view")}
|
||||
</button>
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
.root {
|
||||
[data-slot="title-row"] {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
[data-slot="section-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
[data-slot="setting-row"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
p {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
|
||||
b {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="create-form"] {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="create-form"] {
|
||||
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);
|
||||
margin-top: var(--space-4);
|
||||
|
||||
[data-slot="form-field"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
[data-slot="field-label"] {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="toggle-container"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input[data-component="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="input-row"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--space-3);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="input-field"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
flex: 1;
|
||||
|
||||
p {
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
input[data-component="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);
|
||||
line-height: 1.5;
|
||||
min-width: 0;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-alpha);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background-color: var(--color-bg-surface);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="field-with-connector"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
|
||||
[data-slot="field-connector"] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
input[data-component="input"] {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="form-actions"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
[data-slot="form-error"] {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
margin-top: calc(var(--space-1) * -1);
|
||||
}
|
||||
|
||||
[data-slot="model-toggle-label"] {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 2.5rem;
|
||||
height: 1.5rem;
|
||||
cursor: pointer;
|
||||
|
||||
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="reload-error"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-4);
|
||||
|
||||
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;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
||||
import { createEffect, Show, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import styles from "./reload-section.module.css"
|
||||
import { queryBillingInfo } from "../../common"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { formError, formErrorReloadAmountMin, formErrorReloadTriggerMin, localizeError } from "~/lib/form-error"
|
||||
|
||||
const reload = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(await withActor(() => Billing.reload(), workspaceID), {
|
||||
revalidate: queryBillingInfo.key,
|
||||
})
|
||||
}, "billing.reload")
|
||||
|
||||
const setReload = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const reloadValue = (form.get("reload") as string | null) === "true"
|
||||
const amountStr = form.get("reloadAmount") as string | null
|
||||
const triggerStr = form.get("reloadTrigger") as string | null
|
||||
|
||||
const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null
|
||||
const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null
|
||||
|
||||
if (reloadValue) {
|
||||
if (reloadAmount === null || reloadAmount < Billing.RELOAD_AMOUNT_MIN)
|
||||
return { error: formErrorReloadAmountMin(Billing.RELOAD_AMOUNT_MIN) }
|
||||
if (reloadTrigger === null || reloadTrigger < Billing.RELOAD_TRIGGER_MIN)
|
||||
return { error: formErrorReloadTriggerMin(Billing.RELOAD_TRIGGER_MIN) }
|
||||
}
|
||||
|
||||
return json(
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
reload: reloadValue,
|
||||
...(reloadAmount !== null ? { reloadAmount } : {}),
|
||||
...(reloadTrigger !== null ? { reloadTrigger } : {}),
|
||||
...(reloadValue
|
||||
? {
|
||||
reloadError: null,
|
||||
timeReloadError: null,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID)),
|
||||
),
|
||||
{ revalidate: queryBillingInfo.key },
|
||||
)
|
||||
}, "billing.setReload")
|
||||
|
||||
export function ReloadSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
|
||||
const setReloadSubmission = useSubmission(setReload)
|
||||
const reloadSubmission = useSubmission(reload)
|
||||
const [store, setStore] = createStore({
|
||||
show: false,
|
||||
reload: false,
|
||||
reloadAmount: "",
|
||||
reloadTrigger: "",
|
||||
})
|
||||
|
||||
const processingFee = createMemo(() => {
|
||||
const reloadAmount = billingInfo()?.reloadAmount
|
||||
if (!reloadAmount) return "0.00"
|
||||
return (((reloadAmount + 0.3) / 0.956) * 0.044 + 0.3).toFixed(2)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) {
|
||||
setStore("show", false)
|
||||
}
|
||||
})
|
||||
|
||||
function show() {
|
||||
while (true) {
|
||||
setReloadSubmission.clear()
|
||||
if (!setReloadSubmission.result) break
|
||||
}
|
||||
const info = billingInfo()!
|
||||
setStore("show", true)
|
||||
setStore("reload", true)
|
||||
setStore("reloadAmount", String(info.reloadAmount))
|
||||
setStore("reloadTrigger", String(info.reloadTrigger))
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setStore("show", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.reload.title")}</h2>
|
||||
<div data-slot="title-row">
|
||||
<Show
|
||||
when={billingInfo()?.reload}
|
||||
fallback={
|
||||
<p>
|
||||
{i18n.t("workspace.reload.disabled.before")} <b>{i18n.t("workspace.reload.disabled.state")}</b>.{" "}
|
||||
{i18n.t("workspace.reload.disabled.after")}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
{i18n.t("workspace.reload.enabled.before")} <b>{i18n.t("workspace.reload.enabled.state")}</b>.{" "}
|
||||
{i18n.t("workspace.reload.enabled.middle")} <b>${billingInfo()?.reloadAmount}</b> (+${processingFee()}{" "}
|
||||
{i18n.t("workspace.reload.processingFee")}) {i18n.t("workspace.reload.enabled.after")}{" "}
|
||||
<b>${billingInfo()?.reloadTrigger}</b>.
|
||||
</p>
|
||||
</Show>
|
||||
<button data-color="primary" type="button" onClick={() => show()}>
|
||||
{billingInfo()?.reload ? i18n.t("workspace.reload.edit") : i18n.t("workspace.reload.enable")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={store.show}>
|
||||
<form action={setReload} method="post" data-slot="create-form">
|
||||
<div data-slot="form-field">
|
||||
<label>
|
||||
<span data-slot="field-label">{i18n.t("workspace.reload.enableAutoReload")}</span>
|
||||
<div data-slot="toggle-container">
|
||||
<label data-slot="model-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="reload"
|
||||
value="true"
|
||||
checked={store.reload}
|
||||
onChange={(e) => setStore("reload", e.currentTarget.checked)}
|
||||
/>
|
||||
<span></span>
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div data-slot="input-row">
|
||||
<div data-slot="input-field">
|
||||
<p>{i18n.t("workspace.reload.reloadAmount")}</p>
|
||||
<input
|
||||
data-component="input"
|
||||
name="reloadAmount"
|
||||
type="number"
|
||||
min={String(billingInfo()?.reloadAmountMin ?? "")}
|
||||
step="1"
|
||||
value={store.reloadAmount}
|
||||
onInput={(e) => setStore("reloadAmount", e.currentTarget.value)}
|
||||
placeholder={String(billingInfo()?.reloadAmount ?? "")}
|
||||
disabled={!store.reload}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="input-field">
|
||||
<p>{i18n.t("workspace.reload.whenBalanceReaches")}</p>
|
||||
<input
|
||||
data-component="input"
|
||||
name="reloadTrigger"
|
||||
type="number"
|
||||
min={String(billingInfo()?.reloadTriggerMin ?? "")}
|
||||
step="1"
|
||||
value={store.reloadTrigger}
|
||||
onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)}
|
||||
placeholder={String(billingInfo()?.reloadTrigger ?? "")}
|
||||
disabled={!store.reload}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={setReloadSubmission.result && (setReloadSubmission.result as any).error}>
|
||||
{(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
|
||||
</Show>
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<div data-slot="form-actions">
|
||||
<button type="button" data-color="ghost" onClick={() => hide()}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" data-color="primary" disabled={setReloadSubmission.pending}>
|
||||
{setReloadSubmission.pending ? i18n.t("workspace.reload.saving") : i18n.t("workspace.reload.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Show>
|
||||
<Show when={billingInfo()?.reloadError}>
|
||||
<div data-slot="section-content">
|
||||
<div data-slot="reload-error">
|
||||
<p>
|
||||
{i18n.t("workspace.reload.failedAt")}{" "}
|
||||
{billingInfo()?.timeReloadError!.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}
|
||||
. {i18n.t("workspace.reload.reason")}{" "}
|
||||
{localizeError(i18n.t, billingInfo()?.reloadError ?? undefined).replace(/\.$/, "")}.{" "}
|
||||
{i18n.t("workspace.reload.updatePaymentMethod")}
|
||||
</p>
|
||||
<form action={reload} method="post" data-slot="create-form">
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<button data-color="ghost" type="submit" disabled={reloadSubmission.pending}>
|
||||
{reloadSubmission.pending ? i18n.t("workspace.reload.retrying") : i18n.t("workspace.reload.retry")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IconGo } from "~/component/icon"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import { LiteSection } from "./lite-section"
|
||||
|
||||
export default function () {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
<section data-component="header-section">
|
||||
<IconGo />
|
||||
<p>
|
||||
<span>
|
||||
{i18n.t("workspace.lite.banner.beforeLink")}{" "}
|
||||
<a target="_blank" href={language.route("/docs/go")}>
|
||||
{i18n.t("common.learnMore")}
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div data-slot="sections">
|
||||
<LiteSection />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
.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="beta-notice"] {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg-surface);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-top: var(--space-3);
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
[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-md);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-top: var(--space-2);
|
||||
|
||||
strong {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="promo-models-title"] {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
[data-slot="promo-models"] {
|
||||
margin: var(--space-2) 0 0 var(--space-4);
|
||||
padding: 0;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
[data-slot="subscribe-actions"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
[data-slot="subscribe-button"] {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
[data-slot="other-methods"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
[data-slot="other-methods-icons"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
[data-slot="modal-actions"] {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="method-button"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-2);
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { Modal } from "~/component/modal"
|
||||
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"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import { formError } from "~/lib/form-error"
|
||||
|
||||
import { IconAlipay, IconUpi } from "~/component/icon"
|
||||
|
||||
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, method?: "alipay" | "upi") => {
|
||||
"use server"
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
|
||||
.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") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const useBalance = (form.get("useBalance") as string | null) === "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 language = useLanguage()
|
||||
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
|
||||
const isBlack = createMemo(() => billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked)
|
||||
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({
|
||||
loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
|
||||
showModal: false,
|
||||
})
|
||||
|
||||
const busy = createMemo(() => !!store.loading)
|
||||
|
||||
async function onClickSession() {
|
||||
setStore("loading", "session")
|
||||
const result = await sessionAction(params.id!, window.location.href)
|
||||
if (result.data) {
|
||||
window.location.href = result.data
|
||||
return
|
||||
}
|
||||
setStore("loading", undefined)
|
||||
}
|
||||
|
||||
async function onClickSubscribe(method?: "alipay" | "upi") {
|
||||
setStore("loading", method ?? "checkout")
|
||||
const result = await checkoutAction(params.id!, window.location.href, window.location.href, method)
|
||||
if (result.data) {
|
||||
window.location.href = result.data
|
||||
return
|
||||
}
|
||||
setStore("loading", undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={isBlack()}>
|
||||
<section class={styles.root}>
|
||||
<p data-slot="other-message">{i18n.t("workspace.lite.black.message")}</p>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={!isBlack() && lite() && lite()!.mine && lite()!}>
|
||||
{(sub) => (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<div data-slot="title-row">
|
||||
<p>{i18n.t("workspace.lite.subscription.message")}</p>
|
||||
<button data-color="primary" disabled={sessionSubmission.pending || busy()} onClick={onClickSession}>
|
||||
{store.loading === "session"
|
||||
? i18n.t("workspace.lite.loading")
|
||||
: i18n.t("workspace.lite.subscription.manage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="beta-notice">
|
||||
{i18n.t("workspace.lite.subscription.selectProvider")}{" "}
|
||||
<a href={language.route("/docs/providers/#opencode-go")} target="_blank" rel="noopener noreferrer">
|
||||
{i18n.t("common.learnMore")}
|
||||
</a>
|
||||
.
|
||||
</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={!isBlack() && lite() && !lite()!.mine}>
|
||||
<section class={styles.root}>
|
||||
<p data-slot="other-message">{i18n.t("workspace.lite.other.message")}</p>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={!isBlack() && lite() === null}>
|
||||
<section class={styles.root}>
|
||||
<p data-slot="promo-description">
|
||||
<For
|
||||
each={i18n
|
||||
.t("workspace.lite.promo.description")
|
||||
.split(/(\{\{price\}\})/g)
|
||||
.filter(Boolean)}
|
||||
>
|
||||
{(part) => {
|
||||
if (part === "{{price}}") return <strong>{i18n.t("workspace.lite.promo.price")}</strong>
|
||||
return part
|
||||
}}
|
||||
</For>
|
||||
</p>
|
||||
<h3 data-slot="promo-models-title">{i18n.t("workspace.lite.promo.modelsTitle")}</h3>
|
||||
<ul data-slot="promo-models">
|
||||
<li>Kimi K2.5</li>
|
||||
<li>Kimi K2.6</li>
|
||||
<li>GLM-5</li>
|
||||
<li>GLM-5.1</li>
|
||||
<li>MiMo-V2-Pro</li>
|
||||
<li>MiMo-V2-Omni</li>
|
||||
<li>MiMo-V2.5-Pro</li>
|
||||
<li>MiMo-V2.5</li>
|
||||
<li>MiniMax M2.5</li>
|
||||
<li>MiniMax M2.7</li>
|
||||
<li>Qwen3.5 Plus</li>
|
||||
<li>Qwen3.6 Plus</li>
|
||||
<li>DeepSeek V4 Pro</li>
|
||||
<li>DeepSeek V4 Flash</li>
|
||||
</ul>
|
||||
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
|
||||
<div data-slot="subscribe-actions">
|
||||
<button
|
||||
data-slot="subscribe-button"
|
||||
data-color="primary"
|
||||
disabled={checkoutSubmission.pending || busy()}
|
||||
onClick={() => onClickSubscribe()}
|
||||
>
|
||||
{store.loading === "checkout"
|
||||
? i18n.t("workspace.lite.promo.subscribing")
|
||||
: i18n.t("workspace.lite.promo.subscribe")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="other-methods"
|
||||
data-color="ghost"
|
||||
onClick={() => setStore("showModal", true)}
|
||||
>
|
||||
<span>{i18n.t("workspace.lite.promo.otherMethods")}</span>
|
||||
<span data-slot="other-methods-icons">
|
||||
<span> </span>
|
||||
<IconAlipay style={{ width: "16px", height: "16px" }} />
|
||||
<span> </span>
|
||||
<IconUpi style={{ width: "auto", height: "10px" }} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<Modal
|
||||
open={store.showModal}
|
||||
onClose={() => setStore("showModal", false)}
|
||||
title={i18n.t("workspace.lite.promo.selectMethod")}
|
||||
>
|
||||
<div data-slot="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="method-button"
|
||||
data-color="ghost"
|
||||
disabled={checkoutSubmission.pending || busy()}
|
||||
onClick={() => onClickSubscribe("alipay")}
|
||||
>
|
||||
<Show when={store.loading !== "alipay"}>
|
||||
<IconAlipay style={{ width: "24px", height: "24px" }} />
|
||||
</Show>
|
||||
{store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="method-button"
|
||||
data-color="ghost"
|
||||
disabled={checkoutSubmission.pending || busy()}
|
||||
onClick={() => onClickSubscribe("upi")}
|
||||
>
|
||||
<Show when={store.loading !== "upi"}>
|
||||
<IconUpi style={{ width: "auto", height: "16px" }} />
|
||||
</Show>
|
||||
{store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
|
||||
import { NewUserSection } from "./new-user-section"
|
||||
import { ModelSection } from "./model-section"
|
||||
import { ProviderSection } from "./provider-section"
|
||||
import { IconZen } from "~/component/icon"
|
||||
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
|
||||
export default function () {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const userInfo = createAsync(() => querySessionInfo(params.id!))
|
||||
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
|
||||
const checkoutAction = useAction(createCheckoutUrl)
|
||||
const checkoutSubmission = useSubmission(createCheckoutUrl)
|
||||
const [store, setStore] = createStore({
|
||||
checkoutRedirecting: false,
|
||||
})
|
||||
const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0))
|
||||
|
||||
async function onClickCheckout() {
|
||||
const baseUrl = window.location.href
|
||||
const checkout = await checkoutAction(params.id!, billingInfo()!.reloadAmount, baseUrl, baseUrl)
|
||||
if (checkout && checkout.data) {
|
||||
setStore("checkoutRedirecting", true)
|
||||
window.location.href = checkout.data
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
<section data-component="header-section">
|
||||
<IconZen />
|
||||
<p>
|
||||
<span>
|
||||
{i18n.t("workspace.home.banner.beforeLink")}{" "}
|
||||
<a target="_blank" href={language.route("/docs/zen")}>
|
||||
{i18n.t("common.learnMore")}
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<Show when={userInfo()?.isAdmin}>
|
||||
<span data-slot="billing-info">
|
||||
<Show
|
||||
when={billingInfo()?.customerID}
|
||||
fallback={
|
||||
<button
|
||||
data-color="primary"
|
||||
data-size="sm"
|
||||
disabled={checkoutSubmission.pending || store.checkoutRedirecting}
|
||||
onClick={onClickCheckout}
|
||||
>
|
||||
{checkoutSubmission.pending || store.checkoutRedirecting
|
||||
? i18n.t("workspace.home.billing.loading")
|
||||
: i18n.t("workspace.home.billing.enable")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<span data-slot="balance">
|
||||
{i18n.t("workspace.home.billing.currentBalance")} <b>${balance()}</b>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div data-slot="sections">
|
||||
<NewUserSection />
|
||||
<ModelSection />
|
||||
<Show when={userInfo()?.isAdmin}>
|
||||
<ProviderSection />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { KeySection } from "./key-section"
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
<div data-slot="sections">
|
||||
<KeySection />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
.root {
|
||||
[data-slot="title-row"] {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
[data-component="empty-state"] {
|
||||
padding: var(--space-20) var(--space-6);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="create-form"] {
|
||||
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);
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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="api-keys-table"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
[data-slot="api-keys-table-element"] {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
thead {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&[data-slot="key-name"] {
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&[data-slot="key-value"] {
|
||||
font-family: var(--font-mono);
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin-left: calc(-1 * var(--space-3));
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 400;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-transform: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-bg-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
span {
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-slot="key-date"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&[data-slot="key-actions"] {
|
||||
font-family: var(--font-sans);
|
||||
|
||||
button {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
&:hover {
|
||||
[data-slot="key-actions"] button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
th {
|
||||
&:nth-child(3)
|
||||
|
||||
/* Date */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
&:nth-child(3)
|
||||
|
||||
/* Date */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
||||
import { createEffect, createSignal, For, Show } from "solid-js"
|
||||
import { IconCopy, IconCheck } from "~/component/icon"
|
||||
import { Key } from "@opencode-ai/console-core/key.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { formatDateUTC, formatDateForTable } from "../../common"
|
||||
import styles from "./key-section.module.css"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { formError, localizeError } from "~/lib/form-error"
|
||||
|
||||
const removeKey = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const id = form.get("id") as string | null
|
||||
if (!id) return { error: formError.idRequired }
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
|
||||
}, "key.remove")
|
||||
|
||||
const createKey = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const name = (form.get("name") as string | null)?.trim()
|
||||
if (!name) return { error: formError.nameRequired }
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Key.create({
|
||||
userID: Actor.assert("user").properties.userID,
|
||||
name,
|
||||
})
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({ error: e.message as string })),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: listKeys.key },
|
||||
)
|
||||
}, "key.create")
|
||||
|
||||
const listKeys = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(() => Key.list(), workspaceID)
|
||||
}, "key.list")
|
||||
|
||||
export function KeySection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const keys = createAsync(() => listKeys(params.id!))
|
||||
const submission = useSubmission(createKey)
|
||||
const [store, setStore] = createStore({ show: false })
|
||||
|
||||
let input: HTMLInputElement
|
||||
|
||||
createEffect(() => {
|
||||
if (!submission.pending && submission.result && !submission.result.error) {
|
||||
setStore("show", false)
|
||||
}
|
||||
})
|
||||
|
||||
function show() {
|
||||
while (true) {
|
||||
submission.clear()
|
||||
if (!submission.result) break
|
||||
}
|
||||
setStore("show", true)
|
||||
setTimeout(() => input?.focus(), 0)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setStore("show", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.keys.title")}</h2>
|
||||
<div data-slot="title-row">
|
||||
<p>{i18n.t("workspace.keys.subtitle")}</p>
|
||||
<button data-color="primary" onClick={() => show()}>
|
||||
{i18n.t("workspace.keys.create")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={store.show}>
|
||||
<form action={createKey} method="post" data-slot="create-form">
|
||||
<div data-slot="input-container">
|
||||
<input
|
||||
ref={(r) => (input = r)}
|
||||
data-component="input"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder={i18n.t("workspace.keys.placeholder")}
|
||||
/>
|
||||
<Show when={submission.result && submission.result.error}>
|
||||
{(err) => <div data-slot="form-error">{localizeError(i18n.t, 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()}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||
{submission.pending ? i18n.t("common.creating") : i18n.t("common.create")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Show>
|
||||
<div data-slot="api-keys-table">
|
||||
<Show
|
||||
when={keys()?.length}
|
||||
fallback={
|
||||
<div data-component="empty-state">
|
||||
<p>{i18n.t("workspace.keys.empty")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table data-slot="api-keys-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{i18n.t("workspace.keys.table.name")}</th>
|
||||
<th>{i18n.t("workspace.keys.table.key")}</th>
|
||||
<th>{i18n.t("workspace.keys.table.createdBy")}</th>
|
||||
<th>{i18n.t("workspace.keys.table.lastUsed")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={keys()!}>
|
||||
{(key) => {
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
// const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id)
|
||||
return (
|
||||
<tr>
|
||||
<td data-slot="key-name">{key.name}</td>
|
||||
<td data-slot="key-value">
|
||||
<Show when={key.key} fallback={<span>{key.keyDisplay}</span>}>
|
||||
<button
|
||||
data-color="ghost"
|
||||
disabled={copied()}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(key.key!)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1000)
|
||||
}}
|
||||
title={i18n.t("workspace.keys.copyApiKey")}
|
||||
>
|
||||
<span>{key.keyDisplay}</span>
|
||||
<Show when={copied()} fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}>
|
||||
<IconCheck style={{ width: "14px", height: "14px" }} />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
<td data-slot="key-user-email">{key.email}</td>
|
||||
<td data-slot="key-last-used" title={key.timeUsed ? formatDateUTC(key.timeUsed) : undefined}>
|
||||
{key.timeUsed ? formatDateForTable(key.timeUsed) : "-"}
|
||||
</td>
|
||||
<td data-slot="key-actions">
|
||||
<form action={removeKey} method="post">
|
||||
<input type="hidden" name="id" value={key.id} />
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<button data-color="ghost">{i18n.t("workspace.keys.delete")}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MemberSection } from "./member-section"
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
<div data-slot="sections">
|
||||
<MemberSection />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
.root {
|
||||
[data-slot="title-row"] {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
[data-component="empty-state"] {
|
||||
padding: var(--space-20) var(--space-6);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="beta-notice"] {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg-surface);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--space-3);
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="create-form"] {
|
||||
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);
|
||||
|
||||
[data-slot="input-row"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--space-3);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="input-field"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
flex: 1;
|
||||
|
||||
p {
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
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);
|
||||
line-height: 1.5;
|
||||
min-width: 0;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-alpha);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="form-actions"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
|
||||
> button[type="reset"] {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="form-error"] {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
margin-top: calc(var(--space-1) * -1);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="members-table"] {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 200px;
|
||||
margin-bottom: -200px;
|
||||
}
|
||||
|
||||
[data-slot="members-table-element"] {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
thead {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&[data-slot="member-email"] {
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&[data-slot="member-role"] {
|
||||
font-family: var(--font-mono);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&[data-slot="member-usage"] {
|
||||
input {
|
||||
width: 100%;
|
||||
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);
|
||||
line-height: 1.5;
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-alpha);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-slot="member-date"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&[data-slot="member-actions"] {
|
||||
font-family: var(--font-sans);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
|
||||
[data-slot="inline-edit-form"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
|
||||
button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
form:not([data-slot="inline-edit-form"]) button {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
&:hover {
|
||||
[data-slot="member-actions"] form:not([data-slot="inline-edit-form"]) button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
th {
|
||||
&:nth-child(3)
|
||||
|
||||
/* Date */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
&:nth-child(3)
|
||||
|
||||
/* Date */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
||||
import { createEffect, For, Show } from "solid-js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { createStore } from "solid-js/store"
|
||||
import styles from "./member-section.module.css"
|
||||
import { UserRole } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { User } from "@opencode-ai/console-core/user.js"
|
||||
import { RoleDropdown } from "./role-dropdown"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import { formError, localizeError } from "~/lib/form-error"
|
||||
|
||||
const listMembers = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
return {
|
||||
members: await User.list(),
|
||||
actorID: Actor.userID(),
|
||||
actorRole: Actor.userRole(),
|
||||
}
|
||||
}, workspaceID)
|
||||
}, "member.list")
|
||||
|
||||
const inviteMember = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const email = (form.get("email") as string | null)?.trim()
|
||||
if (!email) return { error: formError.emailRequired }
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const role = form.get("role") as (typeof UserRole)[number] | null
|
||||
if (!role) return { error: formError.roleRequired }
|
||||
const limit = form.get("limit") as string | null
|
||||
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
|
||||
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
User.invite({ email, role, monthlyLimit })
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({ error: e.message as string })),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: listMembers.key },
|
||||
)
|
||||
}, "member.create")
|
||||
|
||||
const removeMember = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const id = form.get("id") as string | null
|
||||
if (!id) return { error: formError.idRequired }
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
User.remove(id)
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({ error: e.message as string })),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: listMembers.key },
|
||||
)
|
||||
}, "member.remove")
|
||||
|
||||
const updateMember = action(async (form: FormData) => {
|
||||
"use server"
|
||||
|
||||
const id = form.get("id") as string | null
|
||||
if (!id) return { error: formError.idRequired }
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const role = form.get("role") as (typeof UserRole)[number] | null
|
||||
if (!role) return { error: formError.roleRequired }
|
||||
const limit = form.get("limit") as string | null
|
||||
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
|
||||
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
User.update({ id, role, monthlyLimit })
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({ error: e.message as string })),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: listMembers.key },
|
||||
)
|
||||
}, "member.update")
|
||||
|
||||
function MemberRow(props: {
|
||||
member: any
|
||||
workspaceID: string
|
||||
actorID: string
|
||||
actorRole: string
|
||||
roleOptions: { value: string; label: string; description: string }[]
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const submission = useSubmission(updateMember)
|
||||
const isCurrentUser = () => props.actorID === props.member.id
|
||||
const isAdmin = () => props.actorRole === "admin"
|
||||
const [store, setStore] = createStore({
|
||||
editing: false,
|
||||
selectedRole: props.member.role as (typeof UserRole)[number],
|
||||
limit: "",
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!submission.pending && submission.result && !submission.result.error) {
|
||||
setStore("editing", false)
|
||||
}
|
||||
})
|
||||
|
||||
function show() {
|
||||
while (true) {
|
||||
submission.clear()
|
||||
if (!submission.result) break
|
||||
}
|
||||
setStore("editing", true)
|
||||
setStore("selectedRole", props.member.role)
|
||||
setStore("limit", props.member.monthlyLimit != null ? String(props.member.monthlyLimit) : "")
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
function getUsageDisplay() {
|
||||
const currentUsage = (() => {
|
||||
const dateLastUsed = props.member.timeMonthlyUsageUpdated
|
||||
if (!dateLastUsed) return 0
|
||||
|
||||
const current = new Date().toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
const lastUsed = dateLastUsed.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
return current === lastUsed ? (props.member.monthlyUsage ?? 0) : 0
|
||||
})()
|
||||
|
||||
const limit = props.member.monthlyLimit
|
||||
? `$${props.member.monthlyLimit}`
|
||||
: i18n.t("workspace.members.noLimitLowercase")
|
||||
return `$${(currentUsage / 100000000).toFixed(2)} / ${limit}`
|
||||
}
|
||||
|
||||
const roleLabel = (value: string) => props.roleOptions.find((option) => option.value === value)?.label ?? value
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td data-slot="member-email">{props.member.authEmail ?? props.member.email}</td>
|
||||
<td data-slot="member-role">
|
||||
<Show when={store.editing && !isCurrentUser()} fallback={<span>{roleLabel(props.member.role)}</span>}>
|
||||
<RoleDropdown
|
||||
value={store.selectedRole}
|
||||
options={props.roleOptions}
|
||||
onChange={(value) => setStore("selectedRole", value as (typeof UserRole)[number])}
|
||||
/>
|
||||
</Show>
|
||||
</td>
|
||||
<td data-slot="member-usage">
|
||||
<Show when={store.editing} fallback={<span>{getUsageDisplay()}</span>}>
|
||||
<input
|
||||
data-component="input"
|
||||
type="number"
|
||||
value={store.limit}
|
||||
onInput={(e) => setStore("limit", e.currentTarget.value)}
|
||||
placeholder={i18n.t("workspace.members.noLimit")}
|
||||
min="0"
|
||||
/>
|
||||
</Show>
|
||||
</td>
|
||||
<td data-slot="member-joined">{props.member.timeSeen ? "" : i18n.t("workspace.members.invited")}</td>
|
||||
<Show when={isAdmin()}>
|
||||
<td data-slot="member-actions">
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<>
|
||||
<button data-color="ghost" onClick={() => show()}>
|
||||
{i18n.t("workspace.members.edit")}
|
||||
</button>
|
||||
<Show when={!isCurrentUser()}>
|
||||
<form action={removeMember} method="post">
|
||||
<input type="hidden" name="id" value={props.member.id} />
|
||||
<input type="hidden" name="workspaceID" value={props.workspaceID} />
|
||||
<button data-color="ghost">{i18n.t("workspace.members.delete")}</button>
|
||||
</form>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form action={updateMember} method="post" data-slot="inline-edit-form">
|
||||
<input type="hidden" name="id" value={props.member.id} />
|
||||
<input type="hidden" name="workspaceID" value={props.workspaceID} />
|
||||
<input type="hidden" name="role" value={store.selectedRole} />
|
||||
<input type="hidden" name="limit" value={store.limit} />
|
||||
<button type="submit" data-color="ghost" disabled={submission.pending}>
|
||||
{submission.pending ? i18n.t("workspace.members.saving") : i18n.t("workspace.members.save")}
|
||||
</button>
|
||||
<Show when={!submission.pending}>
|
||||
<button type="button" data-color="ghost" onClick={() => hide()}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
</Show>
|
||||
</form>
|
||||
</Show>
|
||||
</td>
|
||||
</Show>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function MemberSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const data = createAsync(() => listMembers(params.id!))
|
||||
const submission = useSubmission(inviteMember)
|
||||
const [store, setStore] = createStore({
|
||||
show: false,
|
||||
selectedRole: "member" as (typeof UserRole)[number],
|
||||
limit: "",
|
||||
})
|
||||
|
||||
let input: HTMLInputElement
|
||||
|
||||
const roleOptions = [
|
||||
{
|
||||
value: "admin",
|
||||
label: i18n.t("workspace.members.role.admin"),
|
||||
description: i18n.t("workspace.members.role.adminDescription"),
|
||||
},
|
||||
{
|
||||
value: "member",
|
||||
label: i18n.t("workspace.members.role.member"),
|
||||
description: i18n.t("workspace.members.role.memberDescription"),
|
||||
},
|
||||
]
|
||||
|
||||
createEffect(() => {
|
||||
if (!submission.pending && submission.result && !submission.result.error) {
|
||||
setStore("show", false)
|
||||
}
|
||||
})
|
||||
|
||||
function show() {
|
||||
while (true) {
|
||||
submission.clear()
|
||||
if (!submission.result) break
|
||||
}
|
||||
setStore("show", true)
|
||||
setStore("selectedRole", "member")
|
||||
setStore("limit", "")
|
||||
setTimeout(() => input?.focus(), 0)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setStore("show", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.members.title")}</h2>
|
||||
<div data-slot="title-row">
|
||||
<p>{i18n.t("workspace.members.subtitle")}</p>
|
||||
<Show when={data()?.actorRole === "admin"}>
|
||||
<button data-color="primary" onClick={() => show()}>
|
||||
{i18n.t("workspace.members.invite")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="beta-notice">
|
||||
{i18n.t("workspace.members.beta.beforeLink")}{" "}
|
||||
<a href={language.route("/docs/zen/#for-teams")} target="_blank" rel="noopener noreferrer">
|
||||
{i18n.t("common.learnMore")}
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
<Show when={store.show}>
|
||||
<form action={inviteMember} method="post" data-slot="create-form">
|
||||
<div data-slot="input-row">
|
||||
<div data-slot="input-field">
|
||||
<p>{i18n.t("workspace.members.form.invitee")}</p>
|
||||
<input
|
||||
ref={(r) => (input = r)}
|
||||
data-component="input"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder={i18n.t("workspace.members.form.emailPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="input-field">
|
||||
<p>{i18n.t("workspace.members.form.role")}</p>
|
||||
<RoleDropdown
|
||||
value={store.selectedRole}
|
||||
options={roleOptions}
|
||||
onChange={(value) => setStore("selectedRole", value as (typeof UserRole)[number])}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="input-field">
|
||||
<p>{i18n.t("workspace.members.form.monthlyLimit")}</p>
|
||||
<input
|
||||
data-component="input"
|
||||
name="limit"
|
||||
type="number"
|
||||
placeholder={i18n.t("workspace.members.noLimit")}
|
||||
value={store.limit}
|
||||
onInput={(e) => setStore("limit", e.currentTarget.value)}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={submission.result && submission.result.error}>
|
||||
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
|
||||
</Show>
|
||||
<input type="hidden" name="role" value={store.selectedRole} />
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<div data-slot="form-actions">
|
||||
<button type="reset" data-color="ghost" onClick={() => hide()}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||
{submission.pending ? i18n.t("workspace.members.inviting") : i18n.t("workspace.members.invite")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Show>
|
||||
<div data-slot="members-table">
|
||||
<table data-slot="members-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{i18n.t("workspace.members.table.email")}</th>
|
||||
<th>{i18n.t("workspace.members.table.role")}</th>
|
||||
<th>{i18n.t("workspace.members.table.monthLimit")}</th>
|
||||
<th></th>
|
||||
<Show when={data()?.actorRole === "admin"}>
|
||||
<th></th>
|
||||
</Show>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={data() && data()!.members.length > 0}>
|
||||
<For each={data()!.members}>
|
||||
{(member) => (
|
||||
<MemberRow
|
||||
member={member}
|
||||
workspaceID={params.id!}
|
||||
actorID={data()!.actorID}
|
||||
actorRole={data()!.actorRole}
|
||||
roleOptions={roleOptions}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
.role-dropdown {
|
||||
[data-slot="trigger"] {
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-bg);
|
||||
width: 100%;
|
||||
text-transform: capitalize;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
min-width: 0;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-accent);
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-alpha);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="chevron"] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
[data-slot="dropdown"] {
|
||||
padding: var(--space-1);
|
||||
min-width: 280px;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
[data-slot="role-item"] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
&[data-selected="true"] {
|
||||
background-color: var(--color-accent-alpha);
|
||||
}
|
||||
|
||||
div {
|
||||
strong {
|
||||
display: block;
|
||||
color: var(--color-text);
|
||||
margin-bottom: var(--space-1);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { Dropdown } from "~/component/dropdown"
|
||||
import "./role-dropdown.css"
|
||||
|
||||
interface RoleOption {
|
||||
value: string
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface RoleDropdownProps {
|
||||
value: string
|
||||
options: RoleOption[]
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function RoleDropdown(props: RoleDropdownProps) {
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const selected = () => props.options.find((option) => option.value === props.value)?.label ?? props.value
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
props.onChange(value)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown trigger={selected()} open={open()} onOpenChange={setOpen} class="role-dropdown">
|
||||
<>
|
||||
{props.options.map((option) => (
|
||||
<button
|
||||
data-slot="role-item"
|
||||
data-selected={props.value === option.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.value)}
|
||||
>
|
||||
<div>
|
||||
<strong>{option.label}</strong>
|
||||
<p>{option.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
[data-slot="models-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-slot="models-table"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
[data-slot="models-table-element"] {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
thead {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&[data-slot="model-name"] {
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-slot="training-data"] {
|
||||
text-align: center;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&[data-slot="model-toggle"] {
|
||||
text-align: left;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
[data-slot="model-toggle-label"] {
|
||||
/* Toggle container */
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 2.5rem;
|
||||
height: 1.5rem;
|
||||
cursor: pointer;
|
||||
|
||||
/* Hidden checkbox input */
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Toggle track (background) */
|
||||
span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: #ccc;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
|
||||
/* Toggle handle (slider) */
|
||||
&::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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Checked state - track */
|
||||
input:checked + span {
|
||||
background-color: #21ad0e;
|
||||
border-color: #148605;
|
||||
|
||||
/* Checked state - handle */
|
||||
&::before {
|
||||
transform: translateX(1rem) translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover states */
|
||||
&: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);
|
||||
}
|
||||
|
||||
/* Disabled state */
|
||||
&:has(input:disabled) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input:disabled + span {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input:disabled:checked + span {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
input:disabled ~ span:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&[data-disabled="true"] {
|
||||
td[data-slot="model-name"] {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
[data-slot="models-table-element"] {
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
th {
|
||||
&:nth-child(2)
|
||||
|
||||
/* Training Data */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
&:nth-child(2)
|
||||
|
||||
/* Training Data */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Model } from "@opencode-ai/console-core/model.js"
|
||||
import { query, action, useParams, createAsync, json } from "@solidjs/router"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
import styles from "./model-section.module.css"
|
||||
import { querySessionInfo } from "../common"
|
||||
import {
|
||||
IconAlibaba,
|
||||
IconAnthropic,
|
||||
IconArcee,
|
||||
IconGemini,
|
||||
IconMiniMax,
|
||||
IconMoonshotAI,
|
||||
IconNvidia,
|
||||
IconOpenAI,
|
||||
IconStealth,
|
||||
IconXai,
|
||||
IconXiaomi,
|
||||
IconZai,
|
||||
} from "~/component/icon"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import { formError } from "~/lib/form-error"
|
||||
|
||||
const getModelLab = (modelId: string) => {
|
||||
if (modelId.startsWith("claude")) return "Anthropic"
|
||||
if (modelId.startsWith("gpt")) return "OpenAI"
|
||||
if (modelId.startsWith("gemini")) return "Google"
|
||||
if (modelId.startsWith("kimi")) return "Moonshot AI"
|
||||
if (modelId.startsWith("glm")) return "Z.ai"
|
||||
if (modelId.startsWith("qwen")) return "Alibaba"
|
||||
if (modelId.startsWith("minimax")) return "MiniMax"
|
||||
if (modelId.startsWith("grok")) return "xAI"
|
||||
if (modelId.startsWith("mimo")) return "Xiaomi"
|
||||
if (modelId.startsWith("nemotron")) return "NVIDIA"
|
||||
if (modelId.startsWith("trinity")) return "Arcee"
|
||||
return "Stealth"
|
||||
}
|
||||
|
||||
const getModelsInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
return {
|
||||
all: Object.entries(ZenData.list("full").models)
|
||||
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
|
||||
.filter(([id, _model]) => !id.startsWith("alpha-"))
|
||||
.sort(([idA, modelA], [idB, modelB]) => {
|
||||
const priority = ["big-pickle", "minimax", "grok", "claude", "gpt", "gemini"]
|
||||
const getPriority = (id: string) => {
|
||||
const index = priority.findIndex((p) => id.startsWith(p))
|
||||
return index === -1 ? Infinity : index
|
||||
}
|
||||
const pA = getPriority(idA)
|
||||
const pB = getPriority(idB)
|
||||
if (pA !== pB) return pA - pB
|
||||
|
||||
const modelAName = Array.isArray(modelA) ? modelA[0].name : modelA.name
|
||||
const modelBName = Array.isArray(modelB) ? modelB[0].name : modelB.name
|
||||
return modelAName.localeCompare(modelBName)
|
||||
})
|
||||
.map(([id, model]) => ({ id, name: Array.isArray(model) ? model[0].name : model.name })),
|
||||
disabled: await Model.listDisabled(),
|
||||
}
|
||||
}, workspaceID)
|
||||
}, "model.info")
|
||||
|
||||
const updateModel = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const model = form.get("model") as string | null
|
||||
if (!model) return { error: formError.modelRequired }
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const enabled = (form.get("enabled") as string | null) === "true"
|
||||
return json(
|
||||
withActor(async () => {
|
||||
if (enabled) {
|
||||
await Model.disable({ model })
|
||||
} else {
|
||||
await Model.enable({ model })
|
||||
}
|
||||
}, workspaceID),
|
||||
{ revalidate: getModelsInfo.key },
|
||||
)
|
||||
}, "model.toggle")
|
||||
|
||||
export function ModelSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const modelsInfo = createAsync(() => getModelsInfo(params.id!))
|
||||
const userInfo = createAsync(() => querySessionInfo(params.id!))
|
||||
|
||||
const modelsWithLab = createMemo(() => {
|
||||
const info = modelsInfo()
|
||||
if (!info) return []
|
||||
return info.all.map((model) => ({
|
||||
...model,
|
||||
lab: getModelLab(model.id),
|
||||
}))
|
||||
})
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.models.title")}</h2>
|
||||
<p>
|
||||
{i18n.t("workspace.models.subtitle.beforeLink")}{" "}
|
||||
<a href={language.route("/docs/zen#pricing")}>{i18n.t("common.learnMore")}</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div data-slot="models-list">
|
||||
<Show when={modelsInfo()}>
|
||||
<div data-slot="models-table">
|
||||
<table data-slot="models-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{i18n.t("workspace.models.table.model")}</th>
|
||||
<th></th>
|
||||
<th>{i18n.t("workspace.models.table.enabled")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={modelsWithLab()}>
|
||||
{({ id, name, lab }) => {
|
||||
const isEnabled = createMemo(() => !modelsInfo()!.disabled.includes(id))
|
||||
return (
|
||||
<tr data-slot="model-row" data-disabled={!isEnabled()}>
|
||||
<td data-slot="model-name">
|
||||
<div>
|
||||
{(() => {
|
||||
switch (lab) {
|
||||
case "OpenAI":
|
||||
return <IconOpenAI width={16} height={16} />
|
||||
case "Anthropic":
|
||||
return <IconAnthropic width={16} height={16} />
|
||||
case "Google":
|
||||
return <IconGemini width={16} height={16} />
|
||||
case "Moonshot AI":
|
||||
return <IconMoonshotAI width={16} height={16} />
|
||||
case "Z.ai":
|
||||
return <IconZai width={16} height={16} />
|
||||
case "Alibaba":
|
||||
return <IconAlibaba width={16} height={16} />
|
||||
case "xAI":
|
||||
return <IconXai width={16} height={16} />
|
||||
case "MiniMax":
|
||||
return <IconMiniMax width={16} height={16} />
|
||||
case "Xiaomi":
|
||||
return <IconXiaomi width={16} height={16} />
|
||||
case "NVIDIA":
|
||||
return <IconNvidia width={16} height={16} />
|
||||
case "Arcee":
|
||||
return <IconArcee width={16} height={16} />
|
||||
default:
|
||||
return <IconStealth width={16} height={16} />
|
||||
}
|
||||
})()}
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td data-slot="model-lab">{lab}</td>
|
||||
<td data-slot="model-toggle">
|
||||
<form action={updateModel} method="post">
|
||||
<input type="hidden" name="model" value={id} />
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<input type="hidden" name="enabled" value={String(isEnabled())} />
|
||||
<label data-slot="model-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isEnabled()}
|
||||
disabled={!userInfo()?.isAdmin}
|
||||
onChange={(e) => {
|
||||
const form = e.currentTarget.closest("form")
|
||||
if (form) form.requestSubmit()
|
||||
}}
|
||||
/>
|
||||
<span></span>
|
||||
</label>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-6);
|
||||
background-color: var(--color-bg-surface);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
[data-component="feature-grid"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--space-6);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
[data-slot="feature"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.025rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="api-key-highlight"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
|
||||
[data-slot="key-display"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
[data-slot="key-container"] {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 2px solid var(--color-accent);
|
||||
border-radius: var(--border-radius-sm);
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
[data-slot="key-value"] {
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--space-2-5);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
min-width: 130px;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
justify-content: center;
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
min-width: 96px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="next-steps"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
|
||||
ol {
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
list-style-position: inside;
|
||||
|
||||
li {
|
||||
font-size: var(--font-size-md);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { query, useParams, createAsync } from "@solidjs/router"
|
||||
import { createMemo, createSignal, Show } from "solid-js"
|
||||
import { IconCopy, IconCheck } from "~/component/icon"
|
||||
import { Key } from "@opencode-ai/console-core/key.js"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import styles from "./new-user-section.module.css"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
|
||||
const getUsageInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
return await Billing.usages()
|
||||
}, workspaceID)
|
||||
}, "usage.list")
|
||||
|
||||
const listKeys = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(() => Key.list(), workspaceID)
|
||||
}, "key.list")
|
||||
|
||||
export function NewUserSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const [copiedKey, setCopiedKey] = createSignal(false)
|
||||
const keys = createAsync(() => listKeys(params.id!))
|
||||
const usage = createAsync(() => getUsageInfo(params.id!))
|
||||
const isNew = createMemo(() => {
|
||||
const keysList = keys()
|
||||
const usageList = usage()
|
||||
return keysList?.length === 1 && (!usageList || usageList.length === 0)
|
||||
})
|
||||
const defaultKey = createMemo(() => {
|
||||
const key = keys()?.at(-1)?.key
|
||||
if (!key) return undefined
|
||||
return {
|
||||
actual: key,
|
||||
masked: key.slice(0, 8) + "*".repeat(key.length - 12) + key.slice(-4),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={isNew()}>
|
||||
<div class={styles.root}>
|
||||
<div data-component="feature-grid">
|
||||
<div data-slot="feature">
|
||||
<h3>{i18n.t("workspace.newUser.feature.tested.title")}</h3>
|
||||
<p>{i18n.t("workspace.newUser.feature.tested.body")}</p>
|
||||
</div>
|
||||
<div data-slot="feature">
|
||||
<h3>{i18n.t("workspace.newUser.feature.quality.title")}</h3>
|
||||
<p>{i18n.t("workspace.newUser.feature.quality.body")}</p>
|
||||
</div>
|
||||
<div data-slot="feature">
|
||||
<h3>{i18n.t("workspace.newUser.feature.lockin.title")}</h3>
|
||||
<p>{i18n.t("workspace.newUser.feature.lockin.body")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-component="api-key-highlight">
|
||||
<Show when={defaultKey()}>
|
||||
<div data-slot="key-display">
|
||||
<div data-slot="key-container">
|
||||
<code data-slot="key-value">{defaultKey()?.masked}</code>
|
||||
<button
|
||||
data-color="primary"
|
||||
disabled={copiedKey()}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(defaultKey()?.actual ?? "")
|
||||
setCopiedKey(true)
|
||||
setTimeout(() => setCopiedKey(false), 2000)
|
||||
}}
|
||||
title={i18n.t("workspace.newUser.copyApiKey")}
|
||||
>
|
||||
<Show
|
||||
when={copiedKey()}
|
||||
fallback={
|
||||
<>
|
||||
<IconCopy style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.newUser.copyKey")}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconCheck style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.newUser.copied")}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div data-component="next-steps">
|
||||
<ol>
|
||||
<li>{i18n.t("workspace.newUser.step.enableBilling")}</li>
|
||||
<li>
|
||||
{i18n.t("workspace.newUser.step.login.before")} <code>opencode auth login</code>{" "}
|
||||
{i18n.t("workspace.newUser.step.login.after")}
|
||||
</li>
|
||||
<li>{i18n.t("workspace.newUser.step.pasteKey")}</li>
|
||||
<li>
|
||||
{i18n.t("workspace.newUser.step.models.before")} <code>/models</code>{" "}
|
||||
{i18n.t("workspace.newUser.step.models.after")}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
.root {
|
||||
[data-slot="providers-table"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
[data-slot="providers-table-element"] {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
thead {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
|
||||
&:nth-child(1) {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&[data-slot="provider-name"] {
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&[data-slot="provider-key"] {
|
||||
text-align: left;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
[data-slot="edit-form"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
max-width: 100%;
|
||||
|
||||
[data-slot="input-wrapper"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
max-width: 100%;
|
||||
|
||||
input {
|
||||
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);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&: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="provider-action"] {
|
||||
text-align: left;
|
||||
font-family: var(--font-sans);
|
||||
white-space: nowrap;
|
||||
|
||||
[data-slot="configured-actions"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
|
||||
[data-slot="delete-form"] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
&:hover [data-slot="delete-form"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="form-actions"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
&:hover {
|
||||
[data-slot="provider-action"] [data-slot="delete-form"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
||||
import { createEffect, For, Show } from "solid-js"
|
||||
import { Provider } from "@opencode-ai/console-core/provider.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { createStore } from "solid-js/store"
|
||||
import styles from "./provider-section.module.css"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { formError, localizeError } from "~/lib/form-error"
|
||||
|
||||
const PROVIDERS = [
|
||||
{ name: "OpenAI", key: "openai", prefix: "sk-" },
|
||||
{ name: "Anthropic", key: "anthropic", prefix: "sk-ant-" },
|
||||
{ name: "Google Gemini", key: "google", prefix: "AI" },
|
||||
] as const
|
||||
|
||||
type Provider = (typeof PROVIDERS)[number]
|
||||
|
||||
function maskCredentials(credentials: string) {
|
||||
return `${credentials.slice(0, 8)}...${credentials.slice(-8)}`
|
||||
}
|
||||
|
||||
const removeProvider = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const provider = form.get("provider") as string | null
|
||||
if (!provider) return { error: formError.providerRequired }
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(await withActor(() => Provider.remove({ provider }), workspaceID), {
|
||||
revalidate: listProviders.key,
|
||||
})
|
||||
}, "provider.remove")
|
||||
|
||||
const saveProvider = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const provider = form.get("provider") as string | null
|
||||
const credentials = form.get("credentials") as string | null
|
||||
if (!provider) return { error: formError.providerRequired }
|
||||
if (!credentials) return { error: formError.apiKeyRequired }
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Provider.create({ provider, credentials })
|
||||
.then(() => ({ error: undefined }))
|
||||
.catch((e) => ({ error: e.message as string })),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: listProviders.key },
|
||||
)
|
||||
}, "provider.save")
|
||||
|
||||
const listProviders = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(() => Provider.list(), workspaceID)
|
||||
}, "provider.list")
|
||||
|
||||
function ProviderRow(props: { provider: Provider }) {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const providers = createAsync(() => listProviders(params.id!))
|
||||
const saveSubmission = useSubmission(
|
||||
saveProvider,
|
||||
([fd]) => (fd.get("provider") as string | null) === props.provider.key,
|
||||
)
|
||||
const removeSubmission = useSubmission(
|
||||
removeProvider,
|
||||
([fd]) => (fd.get("provider") as string | null) === props.provider.key,
|
||||
)
|
||||
const [store, setStore] = createStore({ editing: false })
|
||||
|
||||
let input: HTMLInputElement
|
||||
|
||||
const providerData = () => providers()?.find((p) => p.provider === props.provider.key)
|
||||
|
||||
createEffect(() => {
|
||||
if (!saveSubmission.pending && saveSubmission.result && !saveSubmission.result.error) {
|
||||
hide()
|
||||
}
|
||||
})
|
||||
|
||||
function show() {
|
||||
while (true) {
|
||||
saveSubmission.clear()
|
||||
if (!saveSubmission.result) break
|
||||
}
|
||||
setStore("editing", true)
|
||||
setTimeout(() => input?.focus(), 0)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<tr data-slot="provider-row">
|
||||
<td data-slot="provider-name">{props.provider.name}</td>
|
||||
<td data-slot="provider-key">
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={<span>{providerData() ? maskCredentials(providerData()!.credentials) : "-"}</span>}
|
||||
>
|
||||
<form id={`provider-form-${props.provider.key}`} action={saveProvider} method="post" data-slot="edit-form">
|
||||
<div data-slot="input-wrapper">
|
||||
<input
|
||||
ref={(r) => (input = r)}
|
||||
name="credentials"
|
||||
type="text"
|
||||
placeholder={i18n.t("workspace.providers.placeholder", {
|
||||
provider: props.provider.name,
|
||||
prefix: props.provider.prefix,
|
||||
})}
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
/>
|
||||
<Show when={saveSubmission.result && saveSubmission.result.error}>
|
||||
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
<input type="hidden" name="provider" value={props.provider.key} />
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
</form>
|
||||
</Show>
|
||||
</td>
|
||||
<td data-slot="provider-action">
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<Show
|
||||
when={!!providerData()}
|
||||
fallback={
|
||||
<button data-color="ghost" onClick={() => show()}>
|
||||
{i18n.t("workspace.providers.configure")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div data-slot="configured-actions">
|
||||
<button data-color="ghost" onClick={() => show()}>
|
||||
{i18n.t("workspace.providers.edit")}
|
||||
</button>
|
||||
<form action={removeProvider} method="post" data-slot="delete-form">
|
||||
<input type="hidden" name="provider" value={props.provider.key} />
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<button data-color="ghost" type="submit" disabled={removeSubmission.pending}>
|
||||
{i18n.t("workspace.providers.delete")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div data-slot="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
data-color="ghost"
|
||||
disabled={saveSubmission.pending}
|
||||
form={`provider-form-${props.provider.key}`}
|
||||
>
|
||||
{saveSubmission.pending ? i18n.t("workspace.providers.saving") : i18n.t("workspace.providers.save")}
|
||||
</button>
|
||||
<Show when={!saveSubmission.pending}>
|
||||
<button type="reset" data-color="ghost" onClick={() => hide()}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProviderSection() {
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.providers.title")}</h2>
|
||||
<p>{i18n.t("workspace.providers.subtitle")}</p>
|
||||
</div>
|
||||
<div data-slot="providers-table">
|
||||
<table data-slot="providers-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{i18n.t("workspace.providers.table.provider")}</th>
|
||||
<th>{i18n.t("workspace.providers.table.apiKey")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={PROVIDERS}>{(provider) => <ProviderRow provider={provider} />}</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SettingsSection } from "./settings-section"
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
<div data-slot="sections">
|
||||
<SettingsSection />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
.root {
|
||||
max-width: 40rem;
|
||||
|
||||
[data-slot="setting"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
p {
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="value-with-action"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="current-value"] {
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="create-form"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
[data-slot="input-container"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
button {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
line-height: 1.5;
|
||||
min-width: 0;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-alpha);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
> button[type="reset"] {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
[data-slot="form-error"] {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
margin-top: calc(var(--space-1) * -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { json, action, useParams, useSubmission, createAsync, query } from "@solidjs/router"
|
||||
import { createEffect, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Workspace } from "@opencode-ai/console-core/workspace.js"
|
||||
import styles from "./settings-section.module.css"
|
||||
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { formError, localizeError } from "~/lib/form-error"
|
||||
|
||||
const getWorkspaceInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(
|
||||
() =>
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
id: WorkspaceTable.id,
|
||||
name: WorkspaceTable.name,
|
||||
slug: WorkspaceTable.slug,
|
||||
})
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.id, workspaceID))
|
||||
.then((rows) => rows[0] || null),
|
||||
),
|
||||
workspaceID,
|
||||
)
|
||||
}, "workspace.get")
|
||||
|
||||
const updateWorkspace = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const name = (form.get("name") as string | null)?.trim()
|
||||
if (!name) return { error: formError.workspaceNameRequired }
|
||||
if (name.length > 255) return { error: formError.nameTooLong }
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Workspace.update({ name })
|
||||
.then(() => ({ error: undefined }))
|
||||
.catch((e) => ({ error: e.message as string })),
|
||||
workspaceID,
|
||||
),
|
||||
)
|
||||
}, "workspace.update")
|
||||
|
||||
export function SettingsSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const workspaceInfo = createAsync(() => getWorkspaceInfo(params.id!))
|
||||
const submission = useSubmission(updateWorkspace)
|
||||
const [store, setStore] = createStore({ show: false })
|
||||
|
||||
let input: HTMLInputElement
|
||||
|
||||
createEffect(() => {
|
||||
if (!submission.pending && submission.result && !submission.result.error) {
|
||||
hide()
|
||||
}
|
||||
})
|
||||
|
||||
function show() {
|
||||
while (true) {
|
||||
submission.clear()
|
||||
if (!submission.result) break
|
||||
}
|
||||
setStore("show", true)
|
||||
input.focus()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setStore("show", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.settings.title")}</h2>
|
||||
<p>{i18n.t("workspace.settings.subtitle")}</p>
|
||||
</div>
|
||||
<div data-slot="section-content">
|
||||
<div data-slot="setting">
|
||||
<p>{i18n.t("workspace.settings.workspaceName")}</p>
|
||||
<Show
|
||||
when={!store.show}
|
||||
fallback={
|
||||
<form action={updateWorkspace} method="post" data-slot="create-form">
|
||||
<div data-slot="input-container">
|
||||
<input
|
||||
required
|
||||
ref={(r) => (input = r)}
|
||||
data-component="input"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder={i18n.t("workspace.settings.workspaceName")}
|
||||
value={workspaceInfo()?.name ?? i18n.t("workspace.settings.defaultName")}
|
||||
/>
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||
{submission.pending ? i18n.t("workspace.settings.updating") : i18n.t("workspace.settings.save")}
|
||||
</button>
|
||||
<button type="reset" data-color="ghost" onClick={() => hide()}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
<Show when={submission.result && submission.result.error}>
|
||||
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
|
||||
</Show>
|
||||
</form>
|
||||
}
|
||||
>
|
||||
<div data-slot="value-with-action">
|
||||
<p data-slot="current-value">{workspaceInfo()?.name}</p>
|
||||
<button data-color="primary" onClick={() => show()}>
|
||||
{i18n.t("workspace.settings.edit")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
.root {
|
||||
[data-component="empty-state"] {
|
||||
padding: var(--space-20) var(--space-6);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="filter-container"] {
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
[data-component="dropdown"] {
|
||||
[data-slot="trigger"] {
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-alpha);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="chevron"] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
[data-slot="dropdown"] {
|
||||
min-width: 200px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="month-picker"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-slot="month-button"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none !important;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: background-color 0.2s;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="month-label"] {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
min-width: 140px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="model-item"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text);
|
||||
border: none !important;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="chart-container"] {
|
||||
padding: var(--space-6);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
[data-slot="chart-container"] {
|
||||
height: 300px;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
[data-component="empty-state"] {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
import { and, Database, eq, gte, inArray, isNull, lt, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, onCleanup, Show, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Dropdown } from "~/component/dropdown"
|
||||
import { IconChevronLeft, IconChevronRight } from "~/component/icon"
|
||||
import styles from "./graph-section.module.css"
|
||||
import {
|
||||
Chart,
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
type ChartConfiguration,
|
||||
} from "chart.js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
|
||||
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend)
|
||||
|
||||
async function getCosts(workspaceID: string, year: number, month: number, tzOffset: string) {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
const timezoneOffset = (() => {
|
||||
const m = /^([+-])(\d{2}):(\d{2})$/.exec(tzOffset)
|
||||
if (!m) return 0
|
||||
const sign = m[1] === "-" ? -1 : 1
|
||||
return sign * (Number(m[2]) * 60 + Number(m[3])) * 60_000
|
||||
})()
|
||||
|
||||
const monthStartUTC = new Date(Date.UTC(year, month, 1, 0, 0, 0) - timezoneOffset)
|
||||
const monthEndUTC = new Date(Date.UTC(year, month + 1, 1, 0, 0, 0) - timezoneOffset)
|
||||
const dateExpr = sql<string>`DATE(CONVERT_TZ(${UsageTable.timeCreated}, '+00:00', ${tzOffset}))`
|
||||
const usageData = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
date: dateExpr,
|
||||
model: UsageTable.model,
|
||||
totalCost: sum(UsageTable.cost),
|
||||
keyId: UsageTable.keyID,
|
||||
plan: sql<string | null>`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`,
|
||||
})
|
||||
.from(UsageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(UsageTable.workspaceID, workspaceID),
|
||||
gte(UsageTable.timeCreated, monthStartUTC),
|
||||
lt(UsageTable.timeCreated, monthEndUTC),
|
||||
),
|
||||
)
|
||||
.groupBy(dateExpr, UsageTable.model, UsageTable.keyID, sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`)
|
||||
.then((x) =>
|
||||
x.map((r) => ({
|
||||
...r,
|
||||
totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
|
||||
plan: r.plan as "sub" | "lite" | "byok" | null,
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
// Get unique key IDs from usage
|
||||
const usageKeyIds = new Set(usageData.map((r) => r.keyId).filter((id) => id !== null))
|
||||
|
||||
// Second query: get all existing keys plus any keys from usage
|
||||
const keysData = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
keyId: KeyTable.id,
|
||||
keyName: KeyTable.name,
|
||||
userEmail: AuthTable.subject,
|
||||
timeDeleted: KeyTable.timeDeleted,
|
||||
})
|
||||
.from(KeyTable)
|
||||
.innerJoin(UserTable, and(eq(KeyTable.userID, UserTable.id), eq(KeyTable.workspaceID, UserTable.workspaceID)))
|
||||
.innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.where(
|
||||
and(
|
||||
eq(KeyTable.workspaceID, workspaceID),
|
||||
usageKeyIds.size > 0
|
||||
? or(inArray(KeyTable.id, Array.from(usageKeyIds)), isNull(KeyTable.timeDeleted))
|
||||
: isNull(KeyTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.orderBy(AuthTable.subject, KeyTable.name),
|
||||
)
|
||||
|
||||
return {
|
||||
usage: usageData,
|
||||
keys: keysData.map((key) => ({
|
||||
id: key.keyId,
|
||||
displayName: `${key.userEmail} - ${key.keyName}`,
|
||||
deleted: key.timeDeleted !== null,
|
||||
})),
|
||||
}
|
||||
}, workspaceID)
|
||||
}
|
||||
|
||||
const MODEL_COLORS: Record<string, string> = {
|
||||
"claude-sonnet-4-5": "#D4745C",
|
||||
"claude-sonnet-4": "#E8B4A4",
|
||||
"claude-opus-4": "#C8A098",
|
||||
"claude-haiku-4-5": "#F0D8D0",
|
||||
"claude-3-5-haiku": "#F8E8E0",
|
||||
"gpt-5.1": "#4A90E2",
|
||||
"gpt-5.1-codex": "#6BA8F0",
|
||||
"gpt-5": "#7DB8F8",
|
||||
"gpt-5-codex": "#9FCAFF",
|
||||
"gpt-5-nano": "#B8D8FF",
|
||||
"grok-code": "#8B5CF6",
|
||||
"big-pickle": "#10B981",
|
||||
"kimi-k2": "#F59E0B",
|
||||
"qwen3-coder": "#EC4899",
|
||||
"glm-4.6": "#14B8A6",
|
||||
}
|
||||
|
||||
function getModelColor(model: string): string {
|
||||
if (MODEL_COLORS[model]) return MODEL_COLORS[model]
|
||||
|
||||
const hash = model.split("").reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0)
|
||||
const hue = Math.abs(hash) % 360
|
||||
return `hsl(${hue}, 50%, 65%)`
|
||||
}
|
||||
|
||||
function formatDateLabel(dateStr: string): string {
|
||||
const [, m, d] = dateStr.split("-").map(Number)
|
||||
const month = new Date(2000, m - 1, 1).toLocaleDateString(undefined, { month: "short" })
|
||||
return `${month} ${d.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
// Compute the UTC offset (in MySQL CONVERT_TZ format like "+05:30") for the
|
||||
// given IANA timezone at the given instant. Honors DST.
|
||||
function getTimezoneOffset(timezone: string, at: Date): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
.formatToParts(at)
|
||||
.reduce<Record<string, string>>((acc, p) => {
|
||||
if (p.type !== "literal") acc[p.type] = p.value
|
||||
return acc
|
||||
}, {})
|
||||
const asUTC = Date.UTC(
|
||||
Number(parts.year),
|
||||
Number(parts.month) - 1,
|
||||
Number(parts.day),
|
||||
Number(parts.hour),
|
||||
Number(parts.minute),
|
||||
Number(parts.second),
|
||||
)
|
||||
const diffMinutes = Math.round((asUTC - at.getTime()) / 60_000)
|
||||
const sign = diffMinutes < 0 ? "-" : "+"
|
||||
const abs = Math.abs(diffMinutes)
|
||||
const hh = Math.floor(abs / 60)
|
||||
.toString()
|
||||
.padStart(2, "0")
|
||||
const mm = (abs % 60).toString().padStart(2, "0")
|
||||
return `${sign}${hh}:${mm}`
|
||||
}
|
||||
|
||||
function addOpacityToColor(color: string, opacity: number): string {
|
||||
if (color.startsWith("#")) {
|
||||
const r = parseInt(color.slice(1, 3), 16)
|
||||
const g = parseInt(color.slice(3, 5), 16)
|
||||
const b = parseInt(color.slice(5, 7), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`
|
||||
}
|
||||
if (color.startsWith("hsl")) return color.replace(")", `, ${opacity})`).replace("hsl", "hsla")
|
||||
return color
|
||||
}
|
||||
|
||||
export function GraphSection() {
|
||||
let canvasRef: HTMLCanvasElement | undefined
|
||||
let chartInstance: Chart | undefined
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const now = new Date()
|
||||
const [store, setStore] = createStore({
|
||||
data: null as Awaited<ReturnType<typeof getCosts>> | null,
|
||||
year: now.getFullYear(),
|
||||
month: now.getMonth(),
|
||||
key: null as string | null,
|
||||
model: null as string | null,
|
||||
modelDropdownOpen: false,
|
||||
keyDropdownOpen: false,
|
||||
colorScheme: "light" as "light" | "dark",
|
||||
})
|
||||
const onPreviousMonth = async () => {
|
||||
const month = store.month === 0 ? 11 : store.month - 1
|
||||
const year = store.month === 0 ? store.year - 1 : store.year
|
||||
setStore({ month, year })
|
||||
}
|
||||
|
||||
const onNextMonth = async () => {
|
||||
const month = store.month === 11 ? 0 : store.month + 1
|
||||
const year = store.month === 11 ? store.year + 1 : store.year
|
||||
setStore({ month, year })
|
||||
}
|
||||
|
||||
const onSelectModel = (model: string | null) => setStore({ model, modelDropdownOpen: false })
|
||||
|
||||
const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false })
|
||||
|
||||
const getModels = createMemo(() => {
|
||||
if (!store.data?.usage) return []
|
||||
return Array.from(new Set(store.data.usage.map((row) => row.model))).sort()
|
||||
})
|
||||
|
||||
const getDates = createMemo(() => {
|
||||
// Number of days in the month is independent of timezone.
|
||||
const daysInMonth = new Date(Date.UTC(store.year, store.month + 1, 0)).getUTCDate()
|
||||
const yyyy = store.year.toString().padStart(4, "0")
|
||||
const mm = (store.month + 1).toString().padStart(2, "0")
|
||||
return Array.from({ length: daysInMonth }, (_, i) => {
|
||||
const dd = (i + 1).toString().padStart(2, "0")
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
})
|
||||
})
|
||||
|
||||
const getKeyName = (keyID: string | null): string => {
|
||||
if (!keyID || !store.data?.keys) return i18n.t("workspace.cost.allKeys")
|
||||
const found = store.data.keys.find((k) => k.id === keyID)
|
||||
if (!found) return i18n.t("workspace.cost.allKeys")
|
||||
return found.deleted ? `${found.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : found.displayName
|
||||
}
|
||||
|
||||
const formatMonthYear = () =>
|
||||
new Date(store.year, store.month, 1).toLocaleDateString(undefined, { month: "long", year: "numeric" })
|
||||
|
||||
const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
|
||||
|
||||
const chartConfig = createMemo((): ChartConfiguration | null => {
|
||||
const data = store.data
|
||||
const dates = getDates()
|
||||
if (!data?.usage?.length) return null
|
||||
|
||||
store.colorScheme
|
||||
const styles = getComputedStyle(document.documentElement)
|
||||
const colorTextMuted = styles.getPropertyValue("--color-text-muted").trim()
|
||||
const colorBorderMuted = styles.getPropertyValue("--color-border-muted").trim()
|
||||
const colorBgElevated = styles.getPropertyValue("--color-bg-elevated").trim()
|
||||
const colorText = styles.getPropertyValue("--color-text").trim()
|
||||
const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
|
||||
const colorBorder = styles.getPropertyValue("--color-border").trim()
|
||||
const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})`
|
||||
const liteSuffix = " (go)"
|
||||
|
||||
const dailyDataRegular = new Map<string, Map<string, number>>()
|
||||
const dailyDataSub = 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())
|
||||
dailyDataLite.set(dateKey, new Map())
|
||||
}
|
||||
|
||||
data.usage
|
||||
.filter((row) => (store.key ? row.keyId === store.key : true))
|
||||
.forEach((row) => {
|
||||
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)
|
||||
})
|
||||
|
||||
const filteredModels = store.model === null ? getModels() : [store.model]
|
||||
|
||||
// Create datasets: regular first, then subscription, then lite (with visual distinction via opacity)
|
||||
const datasets = [
|
||||
...filteredModels
|
||||
.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) => (dailyDataRegular.get(date)?.get(model) || 0) / 100_000_000),
|
||||
backgroundColor: color,
|
||||
hoverBackgroundColor: color,
|
||||
borderWidth: 0,
|
||||
stack: "usage",
|
||||
}
|
||||
}),
|
||||
...filteredModels
|
||||
.filter((model) => dates.some((date) => (dailyDataSub.get(date)?.get(model) || 0) > 0))
|
||||
.map((model) => {
|
||||
const color = getModelColor(model)
|
||||
return {
|
||||
label: `${model}${subSuffix}`,
|
||||
data: dates.map((date) => (dailyDataSub.get(date)?.get(model) || 0) / 100_000_000),
|
||||
backgroundColor: addOpacityToColor(color, 0.5),
|
||||
hoverBackgroundColor: addOpacityToColor(color, 0.7),
|
||||
borderWidth: 1,
|
||||
borderColor: color,
|
||||
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 {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: dates.map(formatDateLabel),
|
||||
datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
autoSkipPadding: 20,
|
||||
color: colorTextMuted,
|
||||
font: {
|
||||
family: "monospace",
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: colorBorderMuted,
|
||||
},
|
||||
ticks: {
|
||||
color: colorTextMuted,
|
||||
font: {
|
||||
family: "monospace",
|
||||
size: 11,
|
||||
},
|
||||
callback: (value) => {
|
||||
const num = Number(value)
|
||||
return num >= 1000 ? `$${(num / 1000).toFixed(1)}k` : `$${num.toFixed(0)}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
backgroundColor: colorBgElevated,
|
||||
titleColor: colorText,
|
||||
bodyColor: colorTextSecondary,
|
||||
borderColor: colorBorder,
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
displayColors: true,
|
||||
filter: (item) => (item.parsed.y ?? 0) > 0,
|
||||
callbacks: {
|
||||
label: (context) => `${context.dataset.label}: $${(context.parsed.y ?? 0).toFixed(2)}`,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom",
|
||||
labels: {
|
||||
color: colorTextSecondary,
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
padding: 16,
|
||||
boxWidth: 16,
|
||||
boxHeight: 16,
|
||||
usePointStyle: false,
|
||||
},
|
||||
onHover: (event, legendItem, legend) => {
|
||||
const chart = legend.chart
|
||||
chart.data.datasets?.forEach((dataset, i) => {
|
||||
const meta = chart.getDatasetMeta(i)
|
||||
const label = dataset.label || ""
|
||||
const isSub = label.endsWith(subSuffix)
|
||||
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)
|
||||
: 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
|
||||
})
|
||||
})
|
||||
chart.update("none")
|
||||
},
|
||||
onLeave: (event, legendItem, legend) => {
|
||||
const chart = legend.chart
|
||||
chart.data.datasets?.forEach((dataset, i) => {
|
||||
const meta = chart.getDatasetMeta(i)
|
||||
const label = dataset.label || ""
|
||||
const isSub = label.endsWith(subSuffix)
|
||||
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)
|
||||
: isLite
|
||||
? addOpacityToColor(baseColor, 0.35)
|
||||
: baseColor
|
||||
meta.data.forEach((bar: any) => {
|
||||
bar.options.backgroundColor = color
|
||||
})
|
||||
})
|
||||
chart.update("none")
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
// Compute the offset for mid-month so DST transitions don't bias to the
|
||||
// wrong side.
|
||||
const midMonth = new Date(Date.UTC(store.year, store.month, 15, 12, 0, 0))
|
||||
const tzOffset = getTimezoneOffset(timezone, midMonth)
|
||||
const data = await getCosts(params.id!, store.year, store.month, tzOffset)
|
||||
setStore({ data })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const config = chartConfig()
|
||||
if (!config || !canvasRef) return
|
||||
|
||||
if (chartInstance) chartInstance.destroy()
|
||||
chartInstance = new Chart(canvasRef, config)
|
||||
|
||||
onCleanup(() => chartInstance?.destroy())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
setStore({ colorScheme: mediaQuery.matches ? "dark" : "light" })
|
||||
|
||||
const handleColorSchemeChange = (e: MediaQueryListEvent) => {
|
||||
setStore({ colorScheme: e.matches ? "dark" : "light" })
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener("change", handleColorSchemeChange)
|
||||
onCleanup(() => mediaQuery.removeEventListener("change", handleColorSchemeChange))
|
||||
})
|
||||
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.cost.title")}</h2>
|
||||
<p>{i18n.t("workspace.cost.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div data-slot="filter-container">
|
||||
<div data-slot="month-picker">
|
||||
<button data-slot="month-button" onClick={onPreviousMonth}>
|
||||
<IconChevronLeft />
|
||||
</button>
|
||||
<span data-slot="month-label">{formatMonthYear()}</span>
|
||||
<button data-slot="month-button" onClick={onNextMonth} disabled={isCurrentMonth()}>
|
||||
<IconChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
<Dropdown
|
||||
trigger={store.model === null ? i18n.t("workspace.cost.allModels") : store.model}
|
||||
open={store.modelDropdownOpen}
|
||||
onOpenChange={(open) => setStore({ modelDropdownOpen: open })}
|
||||
>
|
||||
<>
|
||||
<button data-slot="model-item" onClick={() => onSelectModel(null)}>
|
||||
<span>{i18n.t("workspace.cost.allModels")}</span>
|
||||
</button>
|
||||
<For each={getModels()}>
|
||||
{(model) => (
|
||||
<button data-slot="model-item" onClick={() => onSelectModel(model)}>
|
||||
<span>{model}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
trigger={getKeyName(store.key)}
|
||||
open={store.keyDropdownOpen}
|
||||
onOpenChange={(open) => setStore({ keyDropdownOpen: open })}
|
||||
>
|
||||
<>
|
||||
<button data-slot="model-item" onClick={() => onSelectKey(null)}>
|
||||
<span>{i18n.t("workspace.cost.allKeys")}</span>
|
||||
</button>
|
||||
<For each={store.data?.keys || []}>
|
||||
{(key) => (
|
||||
<button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
|
||||
<span>
|
||||
{key.deleted ? `${key.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : key.displayName}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={chartConfig()}
|
||||
fallback={
|
||||
<div data-component="empty-state">
|
||||
<p>{i18n.t("workspace.cost.empty")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-slot="chart-container">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Show } from "solid-js"
|
||||
import { createAsync, useParams } from "@solidjs/router"
|
||||
import { GraphSection } from "./graph-section"
|
||||
import { UsageSection } from "./usage-section"
|
||||
import { querySessionInfo } from "../../common"
|
||||
|
||||
export default function () {
|
||||
const params = useParams()
|
||||
const user = createAsync(() => querySessionInfo(params.id!))
|
||||
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
<div data-slot="sections">
|
||||
<Show when={user()?.isAdmin}>
|
||||
<GraphSection />
|
||||
</Show>
|
||||
<UsageSection />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
.root {
|
||||
/* Empty state */
|
||||
[data-component="empty-state"] {
|
||||
padding: var(--space-20) var(--space-6);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* Table container */
|
||||
[data-slot="usage-table"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Table element */
|
||||
[data-slot="usage-table-element"] {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
thead {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&[data-slot="usage-date"] {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
&[data-slot="usage-model"] {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 200px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&[data-slot="usage-cost"] {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="tokens-with-breakdown"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
[data-slot="breakdown-button"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="breakdown-popup"] {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
margin-top: var(--space-2);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2);
|
||||
z-index: 10;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
[data-slot="pagination"] {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) 0;
|
||||
border-top: 1px solid var(--color-border-muted);
|
||||
margin-top: var(--space-2);
|
||||
|
||||
button {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-bg-tertiary);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 40rem) {
|
||||
[data-slot="usage-table-element"] {
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* Hide Model column on mobile */
|
||||
th:nth-child(2),
|
||||
td:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Breakdown popup content */
|
||||
[data-slot="breakdown-row"] {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
[data-slot="breakdown-label"] {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
[data-slot="breakdown-value"] {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
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"
|
||||
import styles from "./usage-section.module.css"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
async function getUsageInfo(workspaceID: string, page: number) {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
return await Billing.usages(page, PAGE_SIZE)
|
||||
}, workspaceID)
|
||||
}
|
||||
|
||||
const queryUsageInfo = query(getUsageInfo, "usage.list")
|
||||
|
||||
export function UsageSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const usage = createAsync(() => queryUsageInfo(params.id!, 0))
|
||||
const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
|
||||
const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null)
|
||||
|
||||
createEffect(() => {
|
||||
setStore({ usage: usage() })
|
||||
}, [usage])
|
||||
|
||||
createEffect(() => {
|
||||
if (!openBreakdownId()) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('[data-slot="tokens-with-breakdown"]')) {
|
||||
setOpenBreakdownId(null)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
return () => document.removeEventListener("click", handleClickOutside)
|
||||
})
|
||||
|
||||
const hasResults = createMemo(() => store.usage && store.usage.length > 0)
|
||||
const canGoPrev = createMemo(() => store.page > 0)
|
||||
const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE)
|
||||
|
||||
const calculateTotalInputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
|
||||
return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0)
|
||||
}
|
||||
|
||||
const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
|
||||
return u.outputTokens + (u.reasoningTokens ?? 0)
|
||||
}
|
||||
|
||||
const goPrev = async () => {
|
||||
const usage = await getUsageInfo(params.id!, store.page - 1)
|
||||
setStore({
|
||||
page: store.page - 1,
|
||||
usage,
|
||||
})
|
||||
}
|
||||
const goNext = async () => {
|
||||
const usage = await getUsageInfo(params.id!, store.page + 1)
|
||||
setStore({
|
||||
page: store.page + 1,
|
||||
usage,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.usage.title")}</h2>
|
||||
<p>{i18n.t("workspace.usage.subtitle")}</p>
|
||||
</div>
|
||||
<div data-slot="usage-table">
|
||||
<Show
|
||||
when={hasResults()}
|
||||
fallback={
|
||||
<div data-component="empty-state">
|
||||
<p>{i18n.t("workspace.usage.empty")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table data-slot="usage-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{i18n.t("workspace.usage.table.date")}</th>
|
||||
<th>{i18n.t("workspace.usage.table.model")}</th>
|
||||
<th>{i18n.t("workspace.usage.table.input")}</th>
|
||||
<th>{i18n.t("workspace.usage.table.output")}</th>
|
||||
<th>{i18n.t("workspace.usage.table.cost")}</th>
|
||||
<th>{i18n.t("workspace.usage.table.session")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={store.usage}>
|
||||
{(usage, index) => {
|
||||
const date = createMemo(() => new Date(usage.timeCreated))
|
||||
const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage))
|
||||
const totalOutputTokens = createMemo(() => calculateTotalOutputTokens(usage))
|
||||
const inputBreakdownId = `input-breakdown-${index()}`
|
||||
const outputBreakdownId = `output-breakdown-${index()}`
|
||||
const isInputOpen = createMemo(() => openBreakdownId() === inputBreakdownId)
|
||||
const isOutputOpen = createMemo(() => openBreakdownId() === outputBreakdownId)
|
||||
const isClaude = usage.model.toLowerCase().includes("claude")
|
||||
return (
|
||||
<tr>
|
||||
<td data-slot="usage-date" title={formatDateUTC(date())}>
|
||||
{formatDateForTable(date())}
|
||||
</td>
|
||||
<td data-slot="usage-model">{usage.model}</td>
|
||||
<td data-slot="usage-tokens">
|
||||
<div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
data-slot="breakdown-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpenBreakdownId(isInputOpen() ? null : inputBreakdownId)
|
||||
}}
|
||||
>
|
||||
<IconBreakdown />
|
||||
</button>
|
||||
<span onClick={() => setOpenBreakdownId(null)}>{totalInputTokens()}</span>
|
||||
<Show when={isInputOpen()}>
|
||||
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
|
||||
<div data-slot="breakdown-row">
|
||||
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.input")}</span>
|
||||
<span data-slot="breakdown-value">{usage.inputTokens}</span>
|
||||
</div>
|
||||
<div data-slot="breakdown-row">
|
||||
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.cacheRead")}</span>
|
||||
<span data-slot="breakdown-value">{usage.cacheReadTokens ?? 0}</span>
|
||||
</div>
|
||||
<Show when={isClaude}>
|
||||
<div data-slot="breakdown-row">
|
||||
<span data-slot="breakdown-label">
|
||||
{i18n.t("workspace.usage.breakdown.cacheWrite")}
|
||||
</span>
|
||||
<span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
<td data-slot="usage-tokens">
|
||||
<div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
data-slot="breakdown-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpenBreakdownId(isOutputOpen() ? null : outputBreakdownId)
|
||||
}}
|
||||
>
|
||||
<IconBreakdown />
|
||||
</button>
|
||||
<span onClick={() => setOpenBreakdownId(null)}>{totalOutputTokens()}</span>
|
||||
<Show when={isOutputOpen()}>
|
||||
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
|
||||
<div data-slot="breakdown-row">
|
||||
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.output")}</span>
|
||||
<span data-slot="breakdown-value">{usage.outputTokens}</span>
|
||||
</div>
|
||||
<div data-slot="breakdown-row">
|
||||
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.reasoning")}</span>
|
||||
<span data-slot="breakdown-value">{usage.reasoningTokens ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
<td data-slot="usage-cost">
|
||||
<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>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
<Show when={canGoPrev() || canGoNext()}>
|
||||
<div data-slot="pagination">
|
||||
<button disabled={!canGoPrev()} onClick={goPrev}>
|
||||
<IconChevronLeft />
|
||||
</button>
|
||||
<button disabled={!canGoNext()} onClick={goNext}>
|
||||
<IconChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
122
qimingcode/packages/console/app/src/routes/workspace/common.tsx
Normal file
122
qimingcode/packages/console/app/src/routes/workspace/common.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { action, json, query } from "@solidjs/router"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { and, Database, desc, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
|
||||
export function formatDateForTable(date: Date) {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
}
|
||||
return date.toLocaleDateString(undefined, options).replace(",", ",")
|
||||
}
|
||||
|
||||
export function formatDateUTC(date: Date) {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZoneName: "short",
|
||||
timeZone: "UTC",
|
||||
}
|
||||
return date.toLocaleDateString(undefined, options)
|
||||
}
|
||||
|
||||
export function formatBalance(amount: number) {
|
||||
const balance = ((amount ?? 0) / 100000000).toFixed(2)
|
||||
return balance === "-0.00" ? "0.00" : balance
|
||||
}
|
||||
|
||||
export async function getLastSeenWorkspaceID() {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
const actor = Actor.assert("account")
|
||||
return Database.use(async (tx) =>
|
||||
tx
|
||||
.select({ id: WorkspaceTable.id })
|
||||
.from(UserTable)
|
||||
.innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(UserTable.accountID, actor.properties.accountID),
|
||||
isNull(UserTable.timeDeleted),
|
||||
isNull(WorkspaceTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(UserTable.timeSeen))
|
||||
.limit(1)
|
||||
.then((x) => x[0]?.id),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const querySessionInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(() => {
|
||||
return {
|
||||
isAdmin: Actor.userRole() === "admin",
|
||||
isBeta: Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true,
|
||||
}
|
||||
}, workspaceID)
|
||||
}, "session.get")
|
||||
|
||||
export const createCheckoutUrl = action(
|
||||
async (workspaceID: string, amount: number, successUrl: string, cancelUrl: string) => {
|
||||
"use server"
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Billing.generateCheckoutUrl({ amount, successUrl, cancelUrl })
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({
|
||||
error: e.message as string,
|
||||
data: undefined,
|
||||
})),
|
||||
workspaceID,
|
||||
),
|
||||
)
|
||||
},
|
||||
"checkoutUrl",
|
||||
)
|
||||
|
||||
export const queryBillingInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
const billing = await Billing.get()
|
||||
return {
|
||||
customerID: billing.customerID,
|
||||
paymentMethodID: billing.paymentMethodID,
|
||||
paymentMethodType: billing.paymentMethodType,
|
||||
paymentMethodLast4: billing.paymentMethodLast4,
|
||||
balance: billing.balance,
|
||||
reload: billing.reload,
|
||||
reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT,
|
||||
reloadAmountMin: Billing.RELOAD_AMOUNT_MIN,
|
||||
reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER,
|
||||
reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN,
|
||||
monthlyLimit: billing.monthlyLimit,
|
||||
monthlyUsage: billing.monthlyUsage,
|
||||
timeMonthlyUsageUpdated: billing.timeMonthlyUsageUpdated,
|
||||
reloadError: billing.reloadError,
|
||||
timeReloadError: billing.timeReloadError,
|
||||
subscription: billing.subscription,
|
||||
subscriptionID: billing.subscriptionID,
|
||||
subscriptionPlan: billing.subscriptionPlan,
|
||||
timeSubscriptionBooked: billing.timeSubscriptionBooked,
|
||||
timeSubscriptionSelected: billing.timeSubscriptionSelected,
|
||||
lite: billing.lite,
|
||||
liteSubscriptionID: billing.liteSubscriptionID,
|
||||
}
|
||||
}, workspaceID)
|
||||
}, "billing.get")
|
||||
Reference in New Issue
Block a user