diff --git a/src/api/engine.ts b/src/api/engine.ts index c3c164e..ded8112 100644 --- a/src/api/engine.ts +++ b/src/api/engine.ts @@ -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) ── export interface ComposeSet { id: number; name: string } diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 7c6215f..24cae55 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState } from 'react' import { useClerk, useOrganization, useUser } from '@clerk/react' +import { useQuery } from '@tanstack/react-query' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n' +import { workspaceCredits } from '../api/engine' import { Brand, Icon, type IconName } from './Icon' 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 ( + + {t('common.credits', { n: credits.data.toLocaleString(locale()) })} + + ) +} + export function AppLayout() { useLang() const [help, setHelp] = useState(false) @@ -66,7 +79,6 @@ export function AppLayout() { const { pathname } = useLocation() const current = NAV.find(n => pathname.startsWith(n.to)) const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-' - const today = new Intl.DateTimeFormat(locale(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }) return (
@@ -101,7 +113,7 @@ export function AppLayout() {
{t('layout.breadcrumb')} / {current ? t(current.label) : ''}
{t('layout.engine')} - {today.format(new Date())} +
diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index fb08cd6..5c3448f 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -22,6 +22,7 @@ const paths: Record = { video: '', audio: '', scorm: '', + coin: '', brain: '', } diff --git a/src/i18n.ts b/src/i18n.ts index 93e9c63..8c73bd6 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -16,6 +16,7 @@ const en = { 'layout.breadcrumb': 'Workspace', 'layout.signOut': 'Sign out', '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.footer': 'A workspace for knowledge that keeps growing.', 'layout.mainNav': 'Main navigation', @@ -397,6 +398,7 @@ const id: Record = { 'layout.breadcrumb': 'Ruang kerja', 'layout.signOut': 'Keluar', '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.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.', 'layout.mainNav': 'Navigasi utama', diff --git a/src/main.tsx b/src/main.tsx index 1f2f6c5..ea070fc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,7 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { ClerkProvider } from '@clerk/react' 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 { EngineError } from './api/client' 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)') } -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: { // 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) }, diff --git a/src/styles/theme.css b/src/styles/theme.css index 3ba7ccb..4f5ae80 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -79,7 +79,9 @@ input::placeholder,textarea::placeholder{color:#98a2b3} .breadcrumb span{color:#d0d5dd} .breadcrumb b{color:var(--ink);font-weight:600} .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} .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} @@ -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} .workspace-main{margin:0;width:100%} .topbar{position:static;height:auto;min-height:56px;padding:10px 16px;flex-wrap:wrap} - .topbar-right{gap:10px} - .topbar-right>span:not(.demo-indicator){display:none} + .topbar-right{gap:8px 10px;flex-wrap:wrap} .demo-strip{margin:16px 16px 0;border-radius:16px;font-size:12px} .app main{padding:22px 16px} .app-footer{margin-left:16px;margin-right:16px}