Show the workspace credit balance in the topbar
Read from GET /organizations/me, refreshed after every action and every 30 seconds, since agents and schedules spend credits in the background. The date is dropped from the topbar, and its items wrap on phones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,12 @@ export async function waitReady(id: number, onProgress?: (d: EngineDocument) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Workspace wallet ──
|
||||||
|
|
||||||
|
// The organization's credit balance: Express spends workspace credits, never personal ones.
|
||||||
|
export const workspaceCredits = async () =>
|
||||||
|
(await request<{ organization: { credit_balance: number } | null }>('/organizations/me')).organization?.credit_balance ?? null
|
||||||
|
|
||||||
// ── Compose (text content) ──
|
// ── Compose (text content) ──
|
||||||
|
|
||||||
export interface ComposeSet { id: number; name: string }
|
export interface ComposeSet { id: number; name: string }
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useClerk, useOrganization, useUser } from '@clerk/react'
|
import { useClerk, useOrganization, useUser } from '@clerk/react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||||
import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n'
|
import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n'
|
||||||
|
import { workspaceCredits } from '../api/engine'
|
||||||
import { Brand, Icon, type IconName } from './Icon'
|
import { Brand, Icon, type IconName } from './Icon'
|
||||||
import { Toaster } from './Toast'
|
import { Toaster } from './Toast'
|
||||||
|
|
||||||
@@ -57,6 +59,17 @@ function HelpDialog({ onClose }: { onClose: () => void }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Polled because agents and schedules also spend credits outside this tab; main.tsx refreshes it after every mutation.
|
||||||
|
function CreditBalance() {
|
||||||
|
const credits = useQuery({ queryKey: ['credits'], queryFn: workspaceCredits, refetchInterval: 30_000 })
|
||||||
|
if (credits.data == null) return null
|
||||||
|
return (
|
||||||
|
<span className="credit-pill" title={t('layout.creditsTitle')}>
|
||||||
|
<Icon name="coin" />{t('common.credits', { n: credits.data.toLocaleString(locale()) })}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
useLang()
|
useLang()
|
||||||
const [help, setHelp] = useState(false)
|
const [help, setHelp] = useState(false)
|
||||||
@@ -66,7 +79,6 @@ export function AppLayout() {
|
|||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const current = NAV.find(n => pathname.startsWith(n.to))
|
const current = NAV.find(n => pathname.startsWith(n.to))
|
||||||
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
|
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
|
||||||
const today = new Intl.DateTimeFormat(locale(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@@ -101,7 +113,7 @@ export function AppLayout() {
|
|||||||
<div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div>
|
<div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div>
|
||||||
<div className="topbar-right">
|
<div className="topbar-right">
|
||||||
<span className="demo-indicator"><i></i> {t('layout.engine')}</span>
|
<span className="demo-indicator"><i></i> {t('layout.engine')}</span>
|
||||||
<span>{today.format(new Date())}</span>
|
<CreditBalance />
|
||||||
<LanguageSwitch />
|
<LanguageSwitch />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const paths: Record<string, string> = {
|
|||||||
video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>',
|
video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>',
|
||||||
audio: '<path d="M4 13v-2a8 8 0 0 1 16 0v2"/><rect x="3" y="11" width="4" height="8" rx="2"/><rect x="17" y="11" width="4" height="8" rx="2"/>',
|
audio: '<path d="M4 13v-2a8 8 0 0 1 16 0v2"/><rect x="3" y="11" width="4" height="8" rx="2"/><rect x="17" y="11" width="4" height="8" rx="2"/>',
|
||||||
scorm: '<path d="m12 3 9 5-9 5-9-5zM3 8v9l9 5 9-5V8M12 13v9"/>',
|
scorm: '<path d="m12 3 9 5-9 5-9-5zM3 8v9l9 5 9-5V8M12 13v9"/>',
|
||||||
|
coin: '<circle cx="12" cy="12" r="9"/><path d="M15 9.5c-.5-1-1.6-1.5-3-1.5-1.7 0-3 .8-3 2s1.3 1.7 3 2 3 .8 3 2-1.3 2-3 2c-1.4 0-2.5-.5-3-1.5M12 6v2M12 16v2"/>',
|
||||||
brain: '<path d="M12 5c-3-4-8-1-7 3-4 2-3 7 0 8-1 4 5 6 7 2M12 5c3-4 8-1 7 3 4 2 3 7 0 8 1 4-5 6-7 2M12 5v15M7 9l3 2M17 9l-3 2M6 15l3-1M18 15l-3-1"/>',
|
brain: '<path d="M12 5c-3-4-8-1-7 3-4 2-3 7 0 8-1 4 5 6 7 2M12 5c3-4 8-1 7 3 4 2 3 7 0 8 1 4-5 6-7 2M12 5v15M7 9l3 2M17 9l-3 2M6 15l3-1M18 15l-3-1"/>',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const en = {
|
|||||||
'layout.breadcrumb': 'Workspace',
|
'layout.breadcrumb': 'Workspace',
|
||||||
'layout.signOut': 'Sign out',
|
'layout.signOut': 'Sign out',
|
||||||
'layout.engine': 'learning-lab engine',
|
'layout.engine': 'learning-lab engine',
|
||||||
|
'layout.creditsTitle': 'Workspace credit balance',
|
||||||
'layout.devStrip': 'Development phase: connected to the learning-lab engine. Review AI output before using it.',
|
'layout.devStrip': 'Development phase: connected to the learning-lab engine. Review AI output before using it.',
|
||||||
'layout.footer': 'A workspace for knowledge that keeps growing.',
|
'layout.footer': 'A workspace for knowledge that keeps growing.',
|
||||||
'layout.mainNav': 'Main navigation',
|
'layout.mainNav': 'Main navigation',
|
||||||
@@ -397,6 +398,7 @@ const id: Record<Key, string> = {
|
|||||||
'layout.breadcrumb': 'Ruang kerja',
|
'layout.breadcrumb': 'Ruang kerja',
|
||||||
'layout.signOut': 'Keluar',
|
'layout.signOut': 'Keluar',
|
||||||
'layout.engine': 'Engine learning-lab',
|
'layout.engine': 'Engine learning-lab',
|
||||||
|
'layout.creditsTitle': 'Saldo kredit workspace',
|
||||||
'layout.devStrip': 'Fase development: terhubung ke engine learning-lab. Hasil AI perlu ditinjau sebelum dipakai.',
|
'layout.devStrip': 'Fase development: terhubung ke engine learning-lab. Hasil AI perlu ditinjau sebelum dipakai.',
|
||||||
'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.',
|
'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.',
|
||||||
'layout.mainNav': 'Navigasi utama',
|
'layout.mainNav': 'Navigasi utama',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { ClerkProvider } from '@clerk/react'
|
import { ClerkProvider } from '@clerk/react'
|
||||||
import { enUS, idID } from '@clerk/localizations'
|
import { enUS, idID } from '@clerk/localizations'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { EngineError } from './api/client'
|
import { EngineError } from './api/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
@@ -16,7 +16,9 @@ if (!clerkPubKey) {
|
|||||||
throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY (set it, or keep ../learning-lab/backend/.env available)')
|
throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY (set it, or keep ../learning-lab/backend/.env available)')
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient: QueryClient = new QueryClient({
|
||||||
|
// Any action may have spent workspace credits, so the topbar balance is read again after each one.
|
||||||
|
mutationCache: new MutationCache({ onSettled: () => queryClient.invalidateQueries({ queryKey: ['credits'] }) }),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
// Retrying a 4xx (auth, credits, validation) only repeats the same answer.
|
// Retrying a 4xx (auth, credits, validation) only repeats the same answer.
|
||||||
queries: { retry: (n, e) => n < 2 && !(e instanceof EngineError && e.status >= 400 && e.status < 500) },
|
queries: { retry: (n, e) => n < 2 && !(e instanceof EngineError && e.status >= 400 && e.status < 500) },
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ input::placeholder,textarea::placeholder{color:#98a2b3}
|
|||||||
.breadcrumb span{color:#d0d5dd}
|
.breadcrumb span{color:#d0d5dd}
|
||||||
.breadcrumb b{color:var(--ink);font-weight:600}
|
.breadcrumb b{color:var(--ink);font-weight:600}
|
||||||
.topbar-right{font-size:13px;color:var(--muted);gap:16px}
|
.topbar-right{font-size:13px;color:var(--muted);gap:16px}
|
||||||
.demo-indicator{height:30px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:#fff;font-size:12.5px;font-weight:550;color:#344054}
|
.demo-indicator{white-space:nowrap;height:30px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:#fff;font-size:12.5px;font-weight:550;color:#344054}
|
||||||
|
.credit-pill{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 12px;border-radius:999px;background:var(--light);color:var(--blue-dark);font-size:12.5px;font-weight:700;white-space:nowrap}
|
||||||
|
.credit-pill svg{width:15px;height:15px}
|
||||||
.demo-indicator i{width:7px;height:7px;background:#059669;box-shadow:0 0 0 3px #d1fae5}
|
.demo-indicator i{width:7px;height:7px;background:#059669;box-shadow:0 0 0 3px #d1fae5}
|
||||||
.lang-switch{border:0;border-radius:999px;background:#f2f4f7;padding:3px}
|
.lang-switch{border:0;border-radius:999px;background:#f2f4f7;padding:3px}
|
||||||
.lang-switch button{border-radius:999px;padding:5px 11px;font-size:12px;color:#475467}
|
.lang-switch button{border-radius:999px;padding:5px 11px;font-size:12px;color:#475467}
|
||||||
@@ -227,8 +229,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
|||||||
.sidebar nav :is(button,a).active::before{display:none}
|
.sidebar nav :is(button,a).active::before{display:none}
|
||||||
.workspace-main{margin:0;width:100%}
|
.workspace-main{margin:0;width:100%}
|
||||||
.topbar{position:static;height:auto;min-height:56px;padding:10px 16px;flex-wrap:wrap}
|
.topbar{position:static;height:auto;min-height:56px;padding:10px 16px;flex-wrap:wrap}
|
||||||
.topbar-right{gap:10px}
|
.topbar-right{gap:8px 10px;flex-wrap:wrap}
|
||||||
.topbar-right>span:not(.demo-indicator){display:none}
|
|
||||||
.demo-strip{margin:16px 16px 0;border-radius:16px;font-size:12px}
|
.demo-strip{margin:16px 16px 0;border-radius:16px;font-size:12px}
|
||||||
.app main{padding:22px 16px}
|
.app main{padding:22px 16px}
|
||||||
.app-footer{margin-left:16px;margin-right:16px}
|
.app-footer{margin-left:16px;margin-right:16px}
|
||||||
|
|||||||
Reference in New Issue
Block a user