feat: enhanced balance panel with usage bars, billing link to billing.hanzo.ai
- Balance tab: session usage progress bars, credit breakdown bars (trial/paid), tier badge, token count, model access list, Add Funds + Manage Billing CTAs - Account dropdown: quick Add Funds button when balance is low/depleted - Update all billing links from hanzo.ai/billing to billing.hanzo.ai - Error messages: point insufficient balance errors to billing.hanzo.ai - Uses same visual pattern as @hanzo/ui/billing UsagePanel (progress bars)
This commit is contained in:
@@ -114,7 +114,7 @@ const errorMessages = {
|
||||
<br />
|
||||
<br />
|
||||
<a
|
||||
href="https://hanzo.ai/billing"
|
||||
href="https://billing.hanzo.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-hanzo-red underline"
|
||||
@@ -133,7 +133,7 @@ const errorMessages = {
|
||||
<br />
|
||||
<br />
|
||||
<a
|
||||
href="https://hanzo.ai/billing"
|
||||
href="https://billing.hanzo.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-hanzo-red underline"
|
||||
@@ -154,7 +154,7 @@ const errorMessages = {
|
||||
<br />
|
||||
<br />
|
||||
<a
|
||||
href="https://hanzo.ai/billing"
|
||||
href="https://billing.hanzo.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-hanzo-red underline"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, memo, useRef } from 'react';
|
||||
import * as Select from '@ariakit/react/select';
|
||||
import { FileText, LogOut } from 'lucide-react';
|
||||
import { FileText, LogOut, CreditCard } from 'lucide-react';
|
||||
import { LinkIcon, GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client';
|
||||
import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal';
|
||||
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
|
||||
@@ -74,6 +74,16 @@ function AccountSettings() {
|
||||
{isEmpty && ' (depleted)'}
|
||||
{isLow && !isEmpty && ' (low)'}
|
||||
</div>
|
||||
{(isEmpty || isLow) && (
|
||||
<Select.SelectItem
|
||||
value=""
|
||||
onClick={() => window.open('https://billing.hanzo.ai', '_blank')}
|
||||
className="select-item text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
<CreditCard className="icon-md" aria-hidden="true" />
|
||||
Add Funds
|
||||
</Select.SelectItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,84 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { ExternalLink, CreditCard, TrendingUp, Zap, ArrowUpRight } from 'lucide-react';
|
||||
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
import TokenCreditsItem from './TokenCreditsItem';
|
||||
import AutoRefillSettings from './AutoRefillSettings';
|
||||
|
||||
const BILLING_URL = 'https://billing.hanzo.ai';
|
||||
|
||||
function creditsToUsd(credits: number): number {
|
||||
return credits / 1_000_000;
|
||||
}
|
||||
|
||||
function formatUsd(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/** Compact token count formatter */
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Progress bar component */
|
||||
function UsageBar({
|
||||
label,
|
||||
sublabel,
|
||||
current,
|
||||
limit,
|
||||
color = 'bg-blue-500',
|
||||
}: {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
current: number;
|
||||
limit: number;
|
||||
color?: string;
|
||||
}) {
|
||||
const pct = limit > 0 ? Math.min((current / limit) * 100, 100) : 0;
|
||||
const isHigh = pct >= 80;
|
||||
const isFull = pct >= 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-text-primary">{label}</span>
|
||||
{sublabel && (
|
||||
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">{sublabel}</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
isFull
|
||||
? 'text-red-500 dark:text-red-400'
|
||||
: isHigh
|
||||
? 'text-yellow-500 dark:text-yellow-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{Math.round(pct)}% used
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ease-out ${
|
||||
isFull ? 'bg-red-500' : isHigh ? 'bg-yellow-500' : color
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Balance() {
|
||||
const localize = useLocalize();
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
@@ -14,7 +89,6 @@ function Balance() {
|
||||
});
|
||||
const balanceData = balanceQuery.data;
|
||||
|
||||
// Pull out all the fields we need, with safe defaults
|
||||
const {
|
||||
tokenCredits = 0,
|
||||
autoRefillEnabled = false,
|
||||
@@ -26,45 +100,122 @@ function Balance() {
|
||||
tierId,
|
||||
trialCredits,
|
||||
paidCredits,
|
||||
allowedModels,
|
||||
} = balanceData ?? {};
|
||||
|
||||
// Check that all auto-refill props are present
|
||||
const hasValidRefillSettings =
|
||||
lastRefill !== undefined &&
|
||||
refillAmount !== undefined &&
|
||||
refillIntervalUnit !== undefined &&
|
||||
refillIntervalValue !== undefined;
|
||||
|
||||
// Calculate usage metrics
|
||||
const usdBalance = creditsToUsd(tokenCredits);
|
||||
const trialUsd = trialCredits != null ? trialCredits / 100 : 0;
|
||||
const paidUsd = paidCredits != null ? paidCredits / 100 : 0;
|
||||
const totalCreditsUsd = trialUsd + paidUsd;
|
||||
|
||||
// Session usage estimate (from start of current period)
|
||||
const sessionUsed = useMemo(() => {
|
||||
if (!totalCreditsUsd || totalCreditsUsd <= 0) return 0;
|
||||
return Math.max(0, totalCreditsUsd - usdBalance);
|
||||
}, [totalCreditsUsd, usdBalance]);
|
||||
|
||||
const tierLabel = tierId && tierId !== 'unknown' ? tierId : 'Free';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 text-sm text-text-primary">
|
||||
{/* Token credits display */}
|
||||
<div className="flex flex-col gap-5 p-4 text-sm text-text-primary">
|
||||
{/* Balance header card */}
|
||||
<div className="rounded-xl border border-border-medium bg-surface-secondary p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-text-secondary">
|
||||
Available Balance
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold text-text-primary">
|
||||
{formatUsd(usdBalance)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-full bg-surface-tertiary px-2.5 py-1">
|
||||
<Zap className="h-3 w-3 text-text-secondary" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-text-secondary">
|
||||
{tierLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick token count */}
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
{formatTokens(tokenCredits)} tokens remaining
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Session / period usage bars */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||
<TrendingUp className="h-3.5 w-3.5" />
|
||||
Usage
|
||||
</h3>
|
||||
|
||||
{/* Current session */}
|
||||
<UsageBar
|
||||
label="Current session"
|
||||
sublabel="Starts when a message is sent"
|
||||
current={sessionUsed}
|
||||
limit={Math.max(totalCreditsUsd, usdBalance, 10)}
|
||||
color="bg-blue-500"
|
||||
/>
|
||||
|
||||
{/* Credit breakdown bars */}
|
||||
{trialUsd > 0 && (
|
||||
<UsageBar
|
||||
label="Trial credit"
|
||||
sublabel={formatUsd(trialUsd)}
|
||||
current={Math.max(0, trialUsd - Math.min(sessionUsed, trialUsd))}
|
||||
limit={trialUsd}
|
||||
color="bg-emerald-500"
|
||||
/>
|
||||
)}
|
||||
|
||||
{paidUsd > 0 && (
|
||||
<UsageBar
|
||||
label="Paid credit"
|
||||
sublabel={formatUsd(paidUsd)}
|
||||
current={paidUsd}
|
||||
limit={paidUsd}
|
||||
color="bg-violet-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token credits detail */}
|
||||
<TokenCreditsItem tokenCredits={tokenCredits} expiresAt={expiresAt} />
|
||||
|
||||
{/* Tier and credit breakdown (when Commerce is connected) */}
|
||||
{tierId && tierId !== 'unknown' && (
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>Tier</span>
|
||||
<span className="capitalize font-medium">{tierId}</span>
|
||||
</div>
|
||||
)}
|
||||
{(trialCredits != null || paidCredits != null) && (
|
||||
<div className="flex flex-col gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{trialCredits != null && trialCredits > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Trial credit</span>
|
||||
<span>${(trialCredits / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
{paidCredits != null && paidCredits > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Paid credit</span>
|
||||
<span>${(paidCredits / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Model access */}
|
||||
{allowedModels && allowedModels.length > 0 && allowedModels[0] !== '*' && (
|
||||
<div className="rounded-lg border border-border-light bg-surface-secondary p-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-text-secondary mb-2">
|
||||
Available Models
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allowedModels.slice(0, 8).map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="inline-flex items-center rounded-md bg-surface-tertiary px-2 py-0.5 text-[11px] font-medium text-text-secondary"
|
||||
>
|
||||
{model}
|
||||
</span>
|
||||
))}
|
||||
{allowedModels.length > 8 && (
|
||||
<span className="text-[11px] text-text-secondary">
|
||||
+{allowedModels.length - 8} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto-refill display */}
|
||||
{/* Auto-refill */}
|
||||
{autoRefillEnabled ? (
|
||||
hasValidRefillSettings ? (
|
||||
<AutoRefillSettings
|
||||
@@ -79,10 +230,34 @@ function Balance() {
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="text-xs text-text-secondary">
|
||||
{localize('com_nav_balance_auto_refill_disabled')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing actions */}
|
||||
<div className="space-y-2 border-t border-border-light pt-4">
|
||||
<a
|
||||
href={BILLING_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-green-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-green-700 active:scale-[0.98]"
|
||||
>
|
||||
<CreditCard className="h-4 w-4" />
|
||||
Add Funds
|
||||
<ArrowUpRight className="h-3.5 w-3.5 opacity-70" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={BILLING_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-border-medium px-4 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover active:scale-[0.98]"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 text-text-secondary" />
|
||||
Manage Billing & Credits
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ const TokenCreditsItem: React.FC<TokenCreditsItemProps> = ({ tokenCredits, expir
|
||||
<div className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||
Low balance.{' '}
|
||||
<a
|
||||
href="https://hanzo.ai/billing"
|
||||
href="https://billing.hanzo.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
@@ -68,7 +68,7 @@ const TokenCreditsItem: React.FC<TokenCreditsItemProps> = ({ tokenCredits, expir
|
||||
<div className="text-xs text-red-600 dark:text-red-400">
|
||||
Credits expired.{' '}
|
||||
<a
|
||||
href="https://hanzo.ai/billing"
|
||||
href="https://billing.hanzo.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
|
||||
@@ -478,7 +478,7 @@
|
||||
"com_nav_font_size_xs": "Extra Small",
|
||||
"com_nav_help_faq": "Help & FAQ",
|
||||
"com_nav_hide_panel": "Hide right-most side panel",
|
||||
"com_nav_info_balance": "Your Hanzo balance in USD. New accounts receive $5.00 in free credits. Add funds at hanzo.ai/billing.",
|
||||
"com_nav_info_balance": "Your Hanzo balance in USD. New accounts receive $5.00 in free credits. Add funds at billing.hanzo.ai.",
|
||||
"com_nav_info_code_artifacts": "Enables the display of experimental code artifacts next to the chat",
|
||||
"com_nav_info_code_artifacts_agent": "Enables the use of code artifacts for this agent. By default, additional instructions specific to the use of artifacts are added, unless \"Custom Prompt Mode\" is enabled.",
|
||||
"com_nav_info_custom_prompt_mode": "When enabled, the default artifacts system prompt will not be included. All artifact-generating instructions must be provided manually in this mode.",
|
||||
|
||||
Reference in New Issue
Block a user