/** * UnoRouter balance — statusbar chip. * * Statusbar shows "left/total (pct%)" of the weekly subscription budget. * Clicking opens a Popover with the full breakdown; hover shows a one-liner. * * Two credit pools exist and the subscription one is spent first when * billing_preference is "subscription_first": * - subscription credit (resets weekly, expires, does NOT carry over) * - wallet balance (top-ups, never expires) * * Endpoints (Bearer access token — NOT an sk- API key): * GET /api/subscription/self -> subscription credit + next reset * GET /api/user/self -> wallet balance (quota) * quota units: 500000 == $1 */ import { cn, haptic, host, icons, PALETTE_AREA, Popover, PopoverContent, PopoverTrigger, queryClient, Separator, STATUSBAR_AREAS, Tip, usePluginI18n, useQuery } from '@hermes/plugin-sdk' import { jsx, jsxs } from 'react/jsx-runtime' const ID = 'unorouter-balance' const API_SUB = 'https://api.unorouter.com/api/subscription/self' const API_SELF = 'https://api.unorouter.com/api/user/self' const BILLING_URL = 'https://unorouter.com/en/billing' const QUOTA_PER_USD = 500000 const REFRESH_MS = 5 * 60 * 1000 const LOW_USD = 2 // Paste your access token here to skip the prompt on first run. const DEFAULT_TOKEN = '' /** Module-scope token mirror — ctx.storage is the source of truth. */ let token = DEFAULT_TOKEN || '' /** Set in register() so module-scope helpers can persist. */ let store = null function setToken(next) { token = String(next || '').trim() } function persistToken() { if (!store) return try { store.set('token', token) } catch { /* storage bridge unavailable — session-only token */ } } async function apiGet(url) { let res try { res = await fetch(url, { method: 'GET', headers: { Authorization: 'Bearer ' + token }, cache: 'no-store' }) } catch (err) { throw new Error('network:' + ((err && err.message) || err)) } if (res.status === 401 || res.status === 403) throw new Error('bad-token') if (!res.ok) throw new Error('http-' + res.status) const body = await res.json() if (!body || body.success === false) { throw new Error(String((body && body.message) || 'api-error')) } return (body && body.data) || {} } /** Pick the live subscription: prefer status active, else the latest end. */ function pickSub(data) { const list = data.subscriptions || data.all_subscriptions || [] const usable = list.filter(e => e && e.subscription) const active = usable.filter(e => e.subscription.status === 'active') const pool = active.length ? active : usable if (!pool.length) return null pool.sort((a, b) => (b.subscription.end_time || 0) - (a.subscription.end_time || 0)) return pool[0] } async function fetchBalance() { if (!token) throw new Error('no-token') const [subData, selfData] = await Promise.all([ apiGet(API_SUB).catch(() => null), apiGet(API_SELF) ]) const wallet = (Number(selfData.quota) || 0) / QUOTA_PER_USD const entry = subData ? pickSub(subData) : null const s = entry ? entry.subscription : null let sub = null if (s) { const total = (Number(s.amount_total) || 0) / QUOTA_PER_USD const used = (Number(s.amount_used) || 0) / QUOTA_PER_USD const left = Math.max(0, total - used) sub = { title: entry.plan_title || '', status: s.status || '', total, used, left, pct: total > 0 ? (100 * left) / total : 0, nextReset: Number(s.next_reset_time) || 0, end: Number(s.end_time) || 0 } } return { sub, wallet, spent: (Number(selfData.used_quota) || 0) / QUOTA_PER_USD, requests: Number(selfData.request_count) || 0, username: selfData.username || '' } } function money(n) { const v = Number(n) || 0 return '$' + v.toFixed(v >= 100 ? 0 : 2) } /** Compact money for the statusbar: no cents on round numbers. */ function shortMoney(n) { const v = Number(n) || 0 return '$' + (Number.isInteger(v) ? String(v) : v.toFixed(2).replace(/0$/, '')) } function shortNum(n) { const v = Number(n) || 0 return Number.isInteger(v) ? String(v) : String(Math.round(v * 100) / 100) } function until(ts) { if (!ts) return null const s = ts - Date.now() / 1000 if (s < 0) return null const d = s / 86400 if (d < 1) return Math.max(1, Math.round(s / 3600)) + 'h' return Math.round(d) + 'd' } /** Statusbar readout: "8.59/10 (86%)". Wallet-only shows spent-able as 100%. */ function statusLabel(d) { const subLeft = d.sub ? d.sub.left : 0 const left = subLeft + d.wallet // A wallet bigger than the plan makes the budget exceed its own total, so // the denominator grows with it and the percentage stays meaningful. const base = d.sub && d.sub.total > 0 ? Math.max(d.sub.total, left) : left const pct = base > 0 ? Math.round((100 * left) / base) : 0 return shortNum(left) + '/' + shortNum(base) + ' (' + pct + '%)' } /* ---------- popover ---------- */ function Row({ label, value, muted }) { return jsxs('div', { className: 'flex items-baseline justify-between gap-3 text-xs', children: [ jsx('span', { className: muted ? 'text-(--ui-text-quaternary)' : 'text-(--ui-text-tertiary)', children: label }), jsx('span', { className: cn('tabular-nums', muted ? 'text-(--ui-text-tertiary)' : 'text-(--ui-text-primary)'), children: value }) ] }) } function Bar({ pct }) { return jsx('div', { className: 'h-1 w-full overflow-hidden rounded-full bg-(--ui-bg-quaternary)', children: jsx('div', { className: 'h-full rounded-full bg-(--ui-accent)', style: { width: Math.max(0, Math.min(100, pct)) + '%' } }) }) } function Details({ data, busy, t, onRefresh }) { const sub = data.sub const left = (sub ? sub.left : 0) + data.wallet const base = sub && sub.total > 0 ? Math.max(sub.total, left) : left const pct = base > 0 ? Math.max(0, Math.min(100, (100 * left) / base)) : 0 const rows = [] if (sub) { rows.push(jsx(Row, { label: t('rowSubscription'), value: money(sub.left) + ' / ' + money(sub.total) })) rows.push(jsx(Row, { label: t('rowPlan'), value: sub.title || '—', muted: true })) const rl = until(sub.nextReset) if (rl) rows.push(jsx(Row, { label: t('rowResets'), value: rl, muted: true })) } rows.push(jsx(Row, { label: t('rowWallet'), value: money(data.wallet) })) rows.push(jsx(Row, { label: t('rowSpent'), value: money(data.spent), muted: true })) rows.push(jsx(Row, { label: t('rowRequests'), value: String(data.requests), muted: true })) return jsxs('div', { className: 'flex flex-col gap-2', children: [ jsxs('div', { className: 'flex items-center justify-between gap-2', children: [ // Provider name — the chip shows only numbers, so the popover has // to say whose budget this is. Primary colour: it's the heading. jsx('span', { className: 'text-xs font-semibold tracking-wide text-(--ui-text-primary)', children: 'UnoRouter' }), jsx(Tip, { label: t('refreshTip'), children: jsx('button', { type: 'button', 'aria-label': t('refreshTip'), disabled: busy, className: cn( 'inline-flex size-5 shrink-0 items-center justify-center rounded-[4px]', 'text-(--ui-text-tertiary) transition-colors', 'hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)', 'disabled:cursor-default disabled:opacity-45' ), onClick: () => { haptic('tap') onRefresh() }, children: jsx(icons.RefreshCw, { size: 12, className: busy ? 'animate-spin' : undefined }) }) }) ] }), jsxs('div', { className: 'flex flex-col gap-1', children: [ jsxs('div', { className: 'flex items-baseline justify-between gap-2', children: [ jsx('span', { className: 'text-xs text-(--ui-text-tertiary)', children: t('rowAvailable') }), jsx('span', { className: 'text-sm font-medium tabular-nums text-(--ui-text-primary)', children: money(left) }) ] }), jsx(Bar, { pct }) ] }), jsx(Separator, {}), jsxs('div', { className: 'flex flex-col gap-1', children: rows }) ] }) } function BalanceChip() { const t = usePluginI18n(ID) const { data, error, isFetching, refetch } = useQuery({ queryKey: [ID, 'balance'], queryFn: fetchBalance, refetchInterval: REFRESH_MS, retry: false, staleTime: 60 * 1000 }) const spendable = data ? (data.sub ? data.sub.left : 0) + data.wallet : null const low = spendable !== null && spendable < LOW_USD const state = error ? String((error && error.message) || error).split(':')[0] : isFetching && !data ? 'loading' : 'ok' // Hover: one line only. let tip if (state === 'no-token') tip = t('tipNoToken') else if (state === 'bad-token') tip = t('tipBadToken') else if (state === 'loading') tip = t('tipLoading') else if (state !== 'ok') tip = t('tipError') + ' ' + state else tip = t('tipOpen') + ' · ' + money(spendable) + ' ' + t('tipAvailable') // Statusbar: left/total (pct%) let label if (state === 'no-token') label = 'ur · set token' else if (state === 'bad-token') label = 'ur · bad token' else if (state === 'loading') label = 'ur · …' else if (state !== 'ok') label = 'ur · ?' else label = statusLabel(data) // Radix's PopoverTrigger renders its OWN