feat: enhance billing panel with auto-recharge and credit management features

- Added auto-recharge settings management, including fetching, updating, and toggling auto-recharge functionality.
- Integrated credit balance and team usage retrieval to provide users with real-time financial insights.
- Implemented card management features, allowing users to add, update, and delete saved payment methods.
- Updated API interactions to support new endpoints for credits and auto-recharge functionalities.
- Improved user experience with loading states and error handling for credit-related operations.
This commit is contained in:
M3gA-Mind
2026-03-18 15:10:05 +05:30
parent 8eaf682d83
commit 5fb37c326a
4 changed files with 891 additions and 118 deletions
+5
View File
@@ -39,7 +39,12 @@
"prepare": "husky"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@noble/hashes": "^2.0.1",
"@noble/secp256k1": "^3.0.0",
"@reduxjs/toolkit": "^2.11.2",
"@scure/bip32": "^2.0.1",
"@scure/bip39": "^2.0.1",
"@sentry/react": "^10.38.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-deep-link": "^2",
+671 -44
View File
@@ -1,6 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { billingApi } from '../../../services/api/billingApi';
import {
type AutoRechargeSettings,
type CreditBalance,
creditsApi,
type SavedCard,
type TeamUsage,
} from '../../../services/api/creditsApi';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { fetchCurrentUser } from '../../../store/userSlice';
import type { PlanTier } from '../../../types/api';
@@ -15,6 +22,25 @@ import {
PLANS,
} from './billingHelpers';
// ── Constants ────────────────────────────────────────────────────────────────
const THRESHOLD_OPTIONS = [5, 10, 20] as const;
const RECHARGE_OPTIONS = [10, 20, 50, 100] as const;
const WEEKLY_LIMIT_OPTIONS = [25, 50, 100, 200, 500] as const;
const CARD_BRAND_LABELS: Record<string, string> = {
visa: 'Visa',
mastercard: 'Mastercard',
amex: 'Amex',
discover: 'Discover',
jcb: 'JCB',
diners: 'Diners',
unionpay: 'UnionPay',
};
function cardBrandLabel(brand: string) {
return CARD_BRAND_LABELS[brand.toLowerCase()] ?? brand.charAt(0).toUpperCase() + brand.slice(1);
}
// ── Component ───────────────────────────────────────────────────────────
const BillingPanel = () => {
const { navigateBack } = useSettingsNavigation();
@@ -31,7 +57,12 @@ const BillingPanel = () => {
const currentTier: PlanTier = activeTeam?.team.subscription?.plan ?? 'FREE';
const hasActive = activeTeam?.team.subscription?.hasActiveSubscription ?? false;
const planExpiry = activeTeam?.team.subscription?.planExpiry;
const usage = user?.usage;
// Credits & usage state
const [creditBalance, setCreditBalance] = useState<CreditBalance | null>(null);
const [teamUsage, setTeamUsage] = useState<TeamUsage | null>(null);
const [isLoadingCredits, setIsLoadingCredits] = useState(false);
const [isToppingUp, setIsToppingUp] = useState(false);
// Local state
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly');
@@ -43,9 +74,45 @@ const BillingPanel = () => {
const pollStartRef = useRef<number>(0);
const timeoutRef = useRef<number | null>(null);
// Fetch current plan on mount
// ── Auto-recharge state ──────────────────────────────────────────────────
const [arSettings, setArSettings] = useState<AutoRechargeSettings | null>(null);
const [arLoading, setArLoading] = useState(true);
const [arError, setArError] = useState<string | null>(null);
const [arSaving, setArSaving] = useState(false);
const [arThreshold, setArThreshold] = useState(5);
const [arAmount, setArAmount] = useState(20);
const [arWeeklyLimit, setArWeeklyLimit] = useState(50);
const [arDirty, setArDirty] = useState(false);
// Recompute dirty flag whenever local settings or server settings change
useEffect(() => {
if (!arSettings) return;
setArDirty(
arThreshold !== arSettings.thresholdUsd ||
arAmount !== arSettings.rechargeAmountUsd ||
arWeeklyLimit !== arSettings.weeklyLimitUsd
);
}, [arThreshold, arAmount, arWeeklyLimit, arSettings]);
// ── Cards state ──────────────────────────────────────────────────────────
const [cards, setCards] = useState<SavedCard[]>([]);
const [cardsLoading, setCardsLoading] = useState(true);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [deletingCardId, setDeletingCardId] = useState<string | null>(null);
const [settingDefaultId, setSettingDefaultId] = useState<string | null>(null);
// Fetch current plan, credits balance, and team usage on mount
useEffect(() => {
billingApi.getCurrentPlan().catch(console.error);
setIsLoadingCredits(true);
Promise.all([creditsApi.getBalance(), creditsApi.getTeamUsage()])
.then(([balance, usage]) => {
setCreditBalance(balance);
setTeamUsage(usage);
})
.catch(console.error)
.finally(() => setIsLoadingCredits(false));
}, []);
// When crypto is selected, force annual
@@ -62,6 +129,130 @@ const BillingPanel = () => {
};
}, []);
// ── Fetch auto-recharge settings + cards on mount ────────────────────────
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const [settings, cardsData] = await Promise.all([
creditsApi.getAutoRecharge(),
creditsApi.getCards(),
]);
if (cancelled) return;
setArSettings(settings);
setArThreshold(settings.thresholdUsd);
setArAmount(settings.rechargeAmountUsd);
setArWeeklyLimit(settings.weeklyLimitUsd);
setCards(cardsData.cards);
} catch {
if (!cancelled) setArError('Failed to load auto-recharge settings.');
} finally {
if (!cancelled) {
setArLoading(false);
setCardsLoading(false);
}
}
};
load().catch(console.error);
return () => {
cancelled = true;
};
}, []);
// ── Auto-recharge handlers ───────────────────────────────────────────────
const handleArToggle = async () => {
if (!arSettings || arSaving) return;
const nextEnabled = !arSettings.enabled;
// Prevent enabling without a saved card
if (nextEnabled && !arSettings.hasSavedPaymentMethod && cards.length === 0) {
setArError('Add a payment card before enabling auto-recharge.');
return;
}
setArSaving(true);
setArError(null);
try {
const updated = await creditsApi.updateAutoRecharge({ enabled: nextEnabled });
setArSettings(updated);
} catch (err) {
const msg =
err && typeof err === 'object' && 'error' in err
? String((err as { error: unknown }).error)
: 'Failed to update auto-recharge.';
setArError(msg);
} finally {
setArSaving(false);
}
};
const handleArSave = async () => {
if (!arSettings || arSaving) return;
setArSaving(true);
setArError(null);
try {
const updated = await creditsApi.updateAutoRecharge({
thresholdUsd: arThreshold,
rechargeAmountUsd: arAmount,
weeklyLimitUsd: arWeeklyLimit,
});
setArSettings(updated);
setArDirty(false);
} catch (err) {
const msg =
err && typeof err === 'object' && 'error' in err
? String((err as { error: unknown }).error)
: 'Failed to save settings.';
setArError(msg);
} finally {
setArSaving(false);
}
};
// ── Card handlers ────────────────────────────────────────────────────────
const handleSetDefault = async (paymentMethodId: string) => {
if (settingDefaultId) return;
setSettingDefaultId(paymentMethodId);
try {
const updated = await creditsApi.updateCard(paymentMethodId, { isDefault: true });
setCards(updated.cards);
} catch {
setArError('Failed to update default card.');
} finally {
setSettingDefaultId(null);
}
};
const handleDeleteCard = async (paymentMethodId: string) => {
if (deletingCardId) return;
setDeletingCardId(paymentMethodId);
setConfirmDeleteId(null);
try {
const updated = await creditsApi.deleteCard(paymentMethodId);
setCards(updated.cards);
// Refresh auto-recharge settings (hasSavedPaymentMethod may change)
const refreshed = await creditsApi.getAutoRecharge();
setArSettings(refreshed);
} catch {
setArError('Failed to remove card.');
} finally {
setDeletingCardId(null);
}
};
const handleAddCard = async () => {
// Card setup requires Stripe.js confirmation.
// Use the Stripe Customer Portal as the secure entry point.
try {
const { portalUrl } = await billingApi.createPortalSession();
await openUrl(portalUrl);
} catch {
setArError('Could not open payment portal. Please try again.');
}
};
// Handle payment:success deep link event
useEffect(() => {
const onPaymentSuccess = async () => {
@@ -102,7 +293,6 @@ const BillingPanel = () => {
currentTierRef.current = currentTier;
}, [currentTier]);
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const startPolling = useCallback(() => {
if (pollRef.current) clearInterval(pollRef.current);
pollStartRef.current = Date.now();
@@ -162,6 +352,18 @@ const BillingPanel = () => {
}
};
const handleTopUp = async (amountUsd: number) => {
setIsToppingUp(true);
try {
const result = await creditsApi.topUp(amountUsd, 'stripe');
await openUrl(result.url);
} catch (err) {
console.error('Top-up failed:', err);
} finally {
setIsToppingUp(false);
}
};
// ── JSX ─────────────────────────────────────────────────────────────
return (
<div className="overflow-hidden flex flex-col">
@@ -174,40 +376,20 @@ const BillingPanel = () => {
{/* <div className="flex items-center justify-between max-w-md mx-auto"> */}
<div className="overflow-y-auto">
<div className="space-y-2">
<div className="max-w-md mt-4 mx-auto">
<div className="p-2.5">
<div className="max-w-md mt-4 mx-auto px-4 space-y-3">
{/* ── Current Plan Header ───────────────────────────────── */}
<div className="rounded-2xl border border-stone-700/50 bg-stone-800/40 p-3">
<div className="flex items-center justify-between mb-1.5">
<h3 className="text-sm font-semibold text-white">
Your Current Plan {currentTier}
</h3>
{usage && (
<span className="text-xs text-stone-400">
{Math.round((usage.spentThisCycleUsd / usage.cycleBudgetUsd) * 100)}% used
</span>
)}
</div>
{hasActive && (
<div className="flex items-center justify-between mb-1.5">
{planExpiry && (
<p className="text-xs text-stone-400">
Renews{' '}
{new Date(planExpiry).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</p>
)}
<h3 className="text-sm font-semibold text-white">Current Plan {currentTier}</h3>
{hasActive && (
<button
onClick={handleManageSubscription}
className="text-xs text-primary-400 hover:text-primary-300 font-medium transition-colors">
Manage Subscription
Manage
</button>
</div>
)}
{/* Renewal date (for non-active subscriptions) */}
{!hasActive && planExpiry && (
)}
</div>
{planExpiry && (
<p className="text-xs text-stone-400 mb-1.5">
Renews{' '}
{new Date(planExpiry).toLocaleDateString('en-US', {
@@ -217,20 +399,131 @@ const BillingPanel = () => {
})}
</p>
)}
{usage && (
<div className="h-1.5 bg-stone-700/60 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300 bg-primary-500"
style={{
width: `${Math.min(
100,
(usage.spentThisCycleUsd / usage.cycleBudgetUsd) * 100
)}%`,
}}
/>
</div>
</div>
{/* ── Inference Budget (Team Usage) ─────────────────────── */}
<div className="rounded-2xl border border-stone-700/50 bg-stone-800/40 p-3">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-white">Inference Budget</h3>
{isLoadingCredits && <span className="text-[10px] text-stone-500">Loading</span>}
{teamUsage && !isLoadingCredits && (
<span className="text-xs text-stone-400">
${teamUsage.remainingUsd.toFixed(2)} / ${teamUsage.cycleBudgetUsd.toFixed(2)}{' '}
remaining
</span>
)}
</div>
{teamUsage ? (
<>
<div className="h-1.5 bg-stone-700/60 rounded-full overflow-hidden mb-2">
<div
className={`h-full rounded-full transition-all duration-300 ${
teamUsage.remainingUsd <= 0
? 'bg-coral-500'
: teamUsage.remainingUsd / teamUsage.cycleBudgetUsd < 0.2
? 'bg-amber-500'
: 'bg-primary-500'
}`}
style={{
width: `${Math.min(100, (teamUsage.remainingUsd / teamUsage.cycleBudgetUsd) * 100)}%`,
}}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[11px] text-stone-500">
Daily usage: ${teamUsage.dailyUsage.toFixed(3)}
</span>
<span className="text-[11px] text-stone-500">
{(
(teamUsage.totalInputTokensThisCycle +
teamUsage.totalOutputTokensThisCycle) /
1000
).toFixed(1)}
k tokens this cycle
</span>
</div>
{teamUsage.remainingUsd <= 0 && (
<p className="text-[11px] text-coral-400 mt-1.5">
Budget exhausted top up your credits to continue using AI features.
</p>
)}
</>
) : isLoadingCredits ? (
<div className="h-1.5 w-full rounded-full bg-stone-700/60 animate-pulse" />
) : (
<p className="text-xs text-stone-500">Unable to load usage data</p>
)}
</div>
{/* ── Credits Balance & Top-up ──────────────────────────── */}
<div className="rounded-2xl border border-stone-700/50 bg-stone-800/40 p-3">
<h3 className="text-sm font-semibold text-white mb-2">Credits Balance</h3>
{creditBalance ? (
<div className="space-y-1.5 mb-3">
<div className="flex items-center justify-between">
<span className="text-xs text-stone-400">General credits</span>
<span className="text-xs font-medium text-white">
${creditBalance.balanceUsd.toFixed(2)}
</span>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs text-stone-400">Top-up credits</span>
<span className="text-xs font-medium text-white">
${creditBalance.topUpBalanceUsd.toFixed(2)}
{creditBalance.topUpBaselineUsd != null &&
creditBalance.topUpBaselineUsd > 0 && (
<span className="text-stone-500 font-normal">
{' '}
/ ${creditBalance.topUpBaselineUsd.toFixed(2)}
</span>
)}
</span>
</div>
{creditBalance.topUpBaselineUsd != null &&
creditBalance.topUpBaselineUsd > 0 && (
<div className="h-1 bg-stone-700/60 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
creditBalance.topUpBalanceUsd <= 0
? 'bg-coral-500'
: creditBalance.topUpBalanceUsd / creditBalance.topUpBaselineUsd <
0.2
? 'bg-amber-500'
: 'bg-primary-500'
}`}
style={{
width: `${Math.min(
100,
(creditBalance.topUpBalanceUsd / creditBalance.topUpBaselineUsd) *
100
)}%`,
}}
/>
</div>
)}
</div>
</div>
) : isLoadingCredits ? (
<div className="space-y-1.5 mb-3">
<div className="h-3 w-full rounded bg-stone-700/60 animate-pulse" />
<div className="h-3 w-3/4 rounded bg-stone-700/60 animate-pulse" />
</div>
) : (
<p className="text-xs text-stone-500 mb-3">Unable to load balance</p>
)}
<div className="flex gap-2">
{[5, 10, 25].map(amount => (
<button
key={amount}
onClick={() => handleTopUp(amount)}
disabled={isToppingUp}
className="flex-1 py-1.5 rounded-lg bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 text-xs font-medium border border-primary-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{isToppingUp ? '…' : `+$${amount}`}
</button>
))}
</div>
</div>
</div>
{/* ── Interval toggle ──────────────────────────────────── */}
@@ -407,6 +700,340 @@ const BillingPanel = () => {
</button>
</div>
{/* ── Auto-Recharge Credits ─────────────────────────────── */}
<div className="px-4 pt-2">
<div className="rounded-2xl border border-stone-700/50 bg-stone-800/40 overflow-hidden">
{/* Header row */}
<div className="flex items-center justify-between p-3">
<div>
<p className="text-xs font-semibold text-white">Auto-Recharge Credits</p>
<p className="text-[11px] text-stone-400 mt-0.5">
Automatically top up when your balance runs low
</p>
</div>
{arLoading ? (
<div className="w-10 h-5 rounded-full bg-stone-700/60 animate-pulse" />
) : (
<button
onClick={handleArToggle}
disabled={arSaving}
role="switch"
aria-checked={arSettings?.enabled ?? false}
aria-label="Toggle auto-recharge"
className={`relative w-10 h-5 rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-900 ${
arSaving ? 'opacity-50 cursor-not-allowed' : ''
} ${arSettings?.enabled ? 'bg-primary-500' : 'bg-stone-600'}`}>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
arSettings?.enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
)}
</div>
{/* Error banner */}
{arError && (
<div className="mx-3 mb-2 flex items-start gap-2 rounded-lg bg-coral-500/10 border border-coral-500/20 px-2.5 py-2">
<svg
className="w-3.5 h-3.5 text-coral-400 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
</svg>
<p className="text-[11px] text-coral-300 leading-relaxed">{arError}</p>
<button
onClick={() => setArError(null)}
className="ml-auto text-coral-400 hover:text-coral-300 flex-shrink-0"
aria-label="Dismiss error">
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
)}
{/* Settings — only shown when enabled */}
{!arLoading && arSettings?.enabled && (
<div className="border-t border-stone-700/50 px-3 pt-3 pb-2 space-y-3">
{/* Status row */}
<div className="flex items-center gap-3 flex-wrap">
{arSettings.inFlight && (
<span className="flex items-center gap-1 text-[10px] text-amber-300 bg-amber-500/10 border border-amber-500/20 rounded-full px-2 py-0.5">
<svg className="w-2.5 h-2.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Recharge in progress
</span>
)}
{arSettings.spentThisWeekUsd > 0 && (
<span className="text-[10px] text-stone-400">
${arSettings.spentThisWeekUsd.toFixed(2)} of ${arSettings.weeklyLimitUsd}{' '}
used this week
</span>
)}
{arSettings.lastRechargeAt && (
<span className="text-[10px] text-stone-500">
Last recharged{' '}
{new Date(arSettings.lastRechargeAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}
</span>
)}
</div>
{/* Last error from recharge attempt */}
{arSettings.lastError && (
<div className="flex items-start gap-1.5 rounded-lg bg-coral-500/10 border border-coral-500/20 px-2.5 py-2">
<svg
className="w-3 h-3 text-coral-400 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
</svg>
<p className="text-[10px] text-coral-300">
Last recharge failed: {arSettings.lastError}
</p>
</div>
)}
{/* Trigger threshold */}
<div>
<p className="text-[11px] text-stone-400 mb-1.5">
Recharge when balance drops below
</p>
<div className="flex gap-1.5 flex-wrap">
{THRESHOLD_OPTIONS.map(v => (
<button
key={v}
onClick={() => setArThreshold(v)}
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
arThreshold === v
? 'bg-primary-500/20 text-primary-400 border-primary-500/40'
: 'bg-stone-700/40 text-stone-400 border-stone-600/40 hover:text-stone-300'
}`}>
${v}
</button>
))}
</div>
</div>
{/* Recharge amount */}
<div>
<p className="text-[11px] text-stone-400 mb-1.5">Add this amount</p>
<div className="flex gap-1.5 flex-wrap">
{RECHARGE_OPTIONS.map(v => (
<button
key={v}
onClick={() => setArAmount(v)}
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
arAmount === v
? 'bg-primary-500/20 text-primary-400 border-primary-500/40'
: 'bg-stone-700/40 text-stone-400 border-stone-600/40 hover:text-stone-300'
}`}>
${v}
</button>
))}
</div>
</div>
{/* Weekly limit */}
<div>
<p className="text-[11px] text-stone-400 mb-1.5">Weekly spending limit</p>
<div className="flex gap-1.5 flex-wrap">
{WEEKLY_LIMIT_OPTIONS.map(v => (
<button
key={v}
onClick={() => setArWeeklyLimit(v)}
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
arWeeklyLimit === v
? 'bg-primary-500/20 text-primary-400 border-primary-500/40'
: 'bg-stone-700/40 text-stone-400 border-stone-600/40 hover:text-stone-300'
}`}>
${v}
</button>
))}
</div>
</div>
{/* Validation hint */}
{arAmount <= arThreshold && (
<p className="text-[10px] text-amber-400">
Recharge amount should be greater than the trigger threshold.
</p>
)}
{/* Save button */}
{arDirty && (
<button
onClick={handleArSave}
disabled={arSaving || arAmount <= arThreshold}
className={`w-full py-1.5 text-xs font-medium rounded-lg transition-colors ${
arSaving || arAmount <= arThreshold
? 'bg-stone-700/40 text-stone-500 cursor-not-allowed'
: 'bg-primary-500 hover:bg-primary-600 text-white'
}`}>
{arSaving ? 'Saving…' : 'Save Settings'}
</button>
)}
</div>
)}
{/* Payment methods */}
<div className="border-t border-stone-700/50 px-3 py-2.5">
<div className="flex items-center justify-between mb-2">
<p className="text-[11px] font-medium text-stone-300">Payment Methods</p>
<button
onClick={handleAddCard}
className="text-[11px] text-primary-400 hover:text-primary-300 font-medium transition-colors">
+ Add card
</button>
</div>
{cardsLoading ? (
<div className="space-y-1.5">
{[0, 1].map(i => (
<div key={i} className="h-9 rounded-lg bg-stone-700/30 animate-pulse" />
))}
</div>
) : cards.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg bg-stone-700/20 border border-stone-700/30 p-2.5">
<svg
className="w-4 h-4 text-stone-500 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
<p className="text-[11px] text-stone-500">
No saved cards. Add one to enable auto-recharge.
</p>
</div>
) : (
<div className="space-y-1.5">
{cards.map(card => {
const isDeleting = deletingCardId === card.id;
const isSettingDefault = settingDefaultId === card.id;
const isConfirming = confirmDeleteId === card.id;
return (
<div
key={card.id}
className="flex items-center gap-2 rounded-lg bg-stone-700/20 border border-stone-700/30 px-2.5 py-2">
{/* Card icon */}
<svg
className="w-4 h-4 text-stone-400 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
{/* Card info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs text-white font-medium">
{cardBrandLabel(card.brand)} {card.last4}
</span>
{card.isDefault && (
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-primary-500/20 text-primary-400 border border-primary-500/30 font-medium">
Default
</span>
)}
</div>
<p className="text-[10px] text-stone-500 mt-0.5">
Expires {String(card.expMonth).padStart(2, '0')}/
{String(card.expYear).slice(-2)}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
{!card.isDefault && (
<button
onClick={() => handleSetDefault(card.id)}
disabled={!!settingDefaultId || !!deletingCardId}
className="text-[10px] text-stone-400 hover:text-stone-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed px-1.5 py-1">
{isSettingDefault ? '…' : 'Set default'}
</button>
)}
{isConfirming ? (
<div className="flex items-center gap-1">
<button
onClick={() => handleDeleteCard(card.id)}
disabled={isDeleting}
className="text-[10px] text-coral-400 hover:text-coral-300 font-medium transition-colors disabled:opacity-40 px-1.5 py-1">
{isDeleting ? '…' : 'Confirm'}
</button>
<button
onClick={() => setConfirmDeleteId(null)}
className="text-[10px] text-stone-500 hover:text-stone-400 transition-colors px-1 py-1">
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDeleteId(card.id)}
disabled={isDeleting || !!settingDefaultId}
className="text-[10px] text-stone-500 hover:text-coral-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed px-1.5 py-1">
Remove
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
{/* ── Upgrade benefits ───────────────────────────────────── */}
<div className="px-4 pb-4 pt-2">
<div className="rounded-xl bg-gradient-to-br from-primary-500/10 to-sage-500/10 border border-primary-500/20 p-4">
+190 -16
View File
@@ -1,25 +1,104 @@
import type { ApiResponse } from '../../types/api';
import { apiClient } from '../apiClient';
interface CreditBalance {
balance: number;
currency: string;
export interface CreditBalance {
balanceUsd: number;
topUpBalanceUsd: number;
topUpBaselineUsd: number | null;
}
interface CreditTransaction {
export interface TeamUsage {
remainingUsd: number;
cycleBudgetUsd: number;
dailyUsage: number;
totalInputTokensThisCycle: number;
totalOutputTokensThisCycle: number;
}
export interface TopUpResult {
url: string;
gatewayTransactionId: string;
amountUsd: number;
gateway: string;
}
export interface CreditTransaction {
id: string;
amount: number;
type: 'credit' | 'debit';
description: string;
type: 'EARN' | 'SPEND';
action: string;
amountUsd: number;
balanceAfterUsd: number;
createdAt: string;
// Add other fields based on backend schema
}
interface PaginatedTransactions {
export interface PaginatedTransactions {
transactions: CreditTransaction[];
totalCount: number;
page: number;
limit: number;
total: number;
}
// ── Auto-Recharge types ──────────────────────────────────────────────────────
export interface AutoRechargeSettings {
enabled: boolean;
thresholdUsd: number;
rechargeAmountUsd: number;
weeklyLimitUsd: number;
spentThisWeekUsd: number;
weekStartDate: string;
inFlight: boolean;
hasSavedPaymentMethod: boolean;
lastTriggeredAt: string | null;
lastRechargeAt: string | null;
lastPaymentIntentId: string | null;
lastError: string | null;
}
export interface AutoRechargeUpdatePayload {
enabled?: boolean;
thresholdUsd?: number;
rechargeAmountUsd?: number;
weeklyLimitUsd?: number;
}
export interface BillingAddress {
line1?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
}
export interface CardBillingDetails {
name?: string;
email?: string;
address?: BillingAddress;
}
export interface SavedCard {
id: string;
brand: string;
expMonth: number;
expYear: number;
isDefault: boolean;
last4: string;
billingDetails: CardBillingDetails;
}
export interface CardsData {
customerId: string;
defaultPaymentMethodId: string;
cards: SavedCard[];
}
export interface SetupIntentData {
clientSecret: string;
customerId: string;
setupIntentId: string;
}
export interface UpdateCardPayload {
isDefault?: boolean;
billingDetails?: CardBillingDetails;
}
/**
@@ -27,11 +106,35 @@ interface PaginatedTransactions {
*/
export const creditsApi = {
/**
* Get the current user's credit balance
* Get the current user's credit balance (general + top-up)
* GET /credits/balance
*/
getBalance: async (): Promise<CreditBalance> => {
const response = await apiClient.get<ApiResponse<CreditBalance>>('/credits/balance');
const response = await apiClient.get<ApiResponse<CreditBalance>>('/payments/credits/balance');
return response.data;
},
/**
* Get team inference budget usage for the current billing cycle
* GET /teams/me/usage
*/
getTeamUsage: async (): Promise<TeamUsage> => {
const response = await apiClient.get<ApiResponse<TeamUsage>>('/teams/me/usage');
return response.data;
},
/**
* Start a top-up (get Stripe or Coinbase payment URL)
* POST /credits/top-up
*/
topUp: async (
amountUsd: number,
gateway: 'stripe' | 'coinbase' = 'stripe'
): Promise<TopUpResult> => {
const response = await apiClient.post<ApiResponse<TopUpResult>>('/payments/credits/top-up', {
amountUsd,
gateway,
});
return response.data;
},
@@ -39,9 +142,80 @@ export const creditsApi = {
* Get paginated credit transaction history
* GET /credits/transactions
*/
getTransactions: async (page = 1, limit = 50): Promise<PaginatedTransactions> => {
getTransactions: async (limit = 20, offset = 0): Promise<PaginatedTransactions> => {
const response = await apiClient.get<ApiResponse<PaginatedTransactions>>(
`/credits/transactions?page=${page}&limit=${limit}`
`/credits/transactions?limit=${limit}&offset=${offset}`
);
return response.data;
},
// ── Auto-Recharge ──────────────────────────────────────────────────────────
/**
* Get auto-recharge settings
* GET /payments/credits/auto-recharge
*/
getAutoRecharge: async (): Promise<AutoRechargeSettings> => {
const response = await apiClient.get<ApiResponse<AutoRechargeSettings>>(
'/payments/credits/auto-recharge'
);
return response.data;
},
/**
* Update auto-recharge settings. Enabling requires a saved card.
* PATCH /payments/credits/auto-recharge
*/
updateAutoRecharge: async (payload: AutoRechargeUpdatePayload): Promise<AutoRechargeSettings> => {
const response = await apiClient.patch<ApiResponse<AutoRechargeSettings>>(
'/payments/credits/auto-recharge',
payload
);
return response.data;
},
/**
* List saved cards for auto-recharge
* GET /payments/credits/auto-recharge/cards
*/
getCards: async (): Promise<CardsData> => {
const response = await apiClient.get<ApiResponse<CardsData>>(
'/payments/credits/auto-recharge/cards'
);
return response.data;
},
/**
* Create a Stripe SetupIntent for adding a new card.
* The returned clientSecret must be confirmed with Stripe.js.
* POST /payments/credits/auto-recharge/cards/setup-intent
*/
createSetupIntent: async (): Promise<SetupIntentData> => {
const response = await apiClient.post<ApiResponse<SetupIntentData>>(
'/payments/credits/auto-recharge/cards/setup-intent'
);
return response.data;
},
/**
* Update a saved card (set as default or update billing details)
* PATCH /payments/credits/auto-recharge/cards/:paymentMethodId
*/
updateCard: async (paymentMethodId: string, payload: UpdateCardPayload): Promise<CardsData> => {
const response = await apiClient.patch<ApiResponse<CardsData>>(
`/payments/credits/auto-recharge/cards/${encodeURIComponent(paymentMethodId)}`,
payload
);
return response.data;
},
/**
* Remove a saved card. If it was the default, another card becomes default.
* DELETE /payments/credits/auto-recharge/cards/:paymentMethodId
*/
deleteCard: async (paymentMethodId: string): Promise<CardsData> => {
const response = await apiClient.delete<ApiResponse<CardsData>>(
`/payments/credits/auto-recharge/cards/${encodeURIComponent(paymentMethodId)}`
);
return response.data;
},
+25 -58
View File
@@ -654,7 +654,7 @@
"@heroicons/react@^2.2.0":
version "2.2.0"
resolved "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz"
resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-2.2.0.tgz#0c05124af50434a800773abec8d3af6a297d904b"
integrity sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==
"@humanfs/core@^0.19.1":
@@ -928,27 +928,22 @@
outvariant "^1.4.3"
strict-event-emitter "^0.5.1"
"@noble/curves@~1.9.0":
version "1.9.7"
resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz"
integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==
"@noble/curves@2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-2.0.1.tgz#64ba8bd5e8564a02942655602515646df1cdb3ad"
integrity sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==
dependencies:
"@noble/hashes" "1.8.0"
"@noble/hashes@1.8.0", "@noble/hashes@~1.8.0":
version "1.8.0"
resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz"
integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==
"@noble/hashes" "2.0.1"
"@noble/hashes@2.0.1", "@noble/hashes@^2.0.1":
version "2.0.1"
resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz"
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e"
integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==
"@noble/secp256k1@^2.0.0":
version "2.3.0"
resolved "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.3.0.tgz"
integrity sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==
"@noble/secp256k1@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-3.0.0.tgz#29711361db8f37b1b7e0b8d80c933013fc887475"
integrity sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
@@ -1181,26 +1176,21 @@
"@scure/base@2.0.0":
version "2.0.0"
resolved "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98"
integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==
"@scure/base@~1.2.5":
version "1.2.6"
resolved "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz"
integrity sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==
"@scure/bip32@^1.5.1":
version "1.7.0"
resolved "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz"
integrity sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==
"@scure/bip32@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-2.0.1.tgz#4ceea207cee8626d3fe8f0b6ab68b6af8f81c482"
integrity sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==
dependencies:
"@noble/curves" "~1.9.0"
"@noble/hashes" "~1.8.0"
"@scure/base" "~1.2.5"
"@noble/curves" "2.0.1"
"@noble/hashes" "2.0.1"
"@scure/base" "2.0.0"
"@scure/bip39@^2.0.1":
version "2.0.1"
resolved "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz"
resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-2.0.1.tgz#47a6dc15e04faf200041239d46ae3bb7c3c96add"
integrity sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==
dependencies:
"@noble/hashes" "2.0.1"
@@ -7405,16 +7395,7 @@ strict-event-emitter@^0.5.1:
resolved "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz"
integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -7513,14 +7494,8 @@ stringify-entities@^4.0.0:
character-entities-html4 "^2.0.0"
character-entities-legacy "^3.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
name strip-ansi-cjs
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -8365,7 +8340,8 @@ workerpool@^6.5.1:
resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz"
integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
name wrap-ansi-cjs
version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -8383,15 +8359,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"