mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Redesign billing: pay-as-you-go first-class + coupon redemption (#337)
* Refactor BillingPanel and introduce new billing components - Refactored the BillingPanel component to streamline its structure and improve readability by removing unused constants and functions. - Introduced new components: AutoRechargeSection, InferenceBudget, PayAsYouGoCard, and SubscriptionPlans to modularize billing functionalities. - Added coupon redemption functionality in the credits API, allowing users to redeem coupon codes for credits. - Enhanced the overall billing experience by improving the layout and organization of billing-related UI elements. * style: apply prettier formatting to billing components Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7bce9d2bbe
commit
09b627ccc9
@@ -14,36 +14,13 @@ import type { CurrentPlanData, PlanTier } from '../../../types/api';
|
||||
import { openUrl } from '../../../utils/openUrl';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import {
|
||||
annualSavings,
|
||||
buildPlanId,
|
||||
isUpgrade as checkIsUpgrade,
|
||||
displayPrice,
|
||||
formatStorageLimit,
|
||||
formatUsdAmount,
|
||||
getPlanMeta,
|
||||
PLANS,
|
||||
} from './billingHelpers';
|
||||
import AutoRechargeSection from './billing/AutoRechargeSection';
|
||||
import InferenceBudget from './billing/InferenceBudget';
|
||||
import PayAsYouGoCard from './billing/PayAsYouGoCard';
|
||||
import SubscriptionPlans from './billing/SubscriptionPlans';
|
||||
import { buildPlanId, formatStorageLimit, formatUsdAmount, getPlanMeta } from './billingHelpers';
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
const log = createDebug('openhuman:billing-panel');
|
||||
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 = () => {
|
||||
@@ -181,7 +158,6 @@ const BillingPanel = () => {
|
||||
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;
|
||||
@@ -247,7 +223,6 @@ const BillingPanel = () => {
|
||||
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 {
|
||||
@@ -258,8 +233,6 @@ const BillingPanel = () => {
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -271,7 +244,6 @@ const BillingPanel = () => {
|
||||
// Handle payment:success deep link event
|
||||
useEffect(() => {
|
||||
const onPaymentSuccess = async () => {
|
||||
// Stop any in-flight poll — we know checkout completed
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
@@ -280,7 +252,6 @@ const BillingPanel = () => {
|
||||
setPurchasingTier(null);
|
||||
setPaymentConfirmed(true);
|
||||
|
||||
// Fetch current plan from backend, then refresh user/teams in store
|
||||
try {
|
||||
const plan = await billingApi.getCurrentPlan();
|
||||
log('[payment-success] plan=%s active=%s', plan.plan, plan.hasActiveSubscription);
|
||||
@@ -290,7 +261,6 @@ const BillingPanel = () => {
|
||||
}
|
||||
await refresh();
|
||||
|
||||
// Auto-hide the success banner after 5 s
|
||||
timeoutRef.current = window.setTimeout(() => setPaymentConfirmed(false), 5_000);
|
||||
};
|
||||
|
||||
@@ -315,7 +285,6 @@ const BillingPanel = () => {
|
||||
pollStartRef.current = Date.now();
|
||||
|
||||
pollRef.current = setInterval(async () => {
|
||||
// Stop after 2 minutes
|
||||
if (Date.now() - pollStartRef.current > 120_000) {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
setIsPurchasing(false);
|
||||
@@ -386,6 +355,15 @@ const BillingPanel = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBalanceRefresh = useCallback(async () => {
|
||||
try {
|
||||
const balance = await creditsApi.getBalance();
|
||||
setCreditBalance(balance);
|
||||
} catch (err) {
|
||||
log('[balance-refresh] failed: %O', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── JSX ─────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div>
|
||||
@@ -395,7 +373,6 @@ const BillingPanel = () => {
|
||||
onBack={navigateBack}
|
||||
/>
|
||||
|
||||
{/* <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 px-4 space-y-3">
|
||||
@@ -452,671 +429,72 @@ const BillingPanel = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Inference Budget (Team Usage) ─────────────────────── */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-stone-900">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>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span className="text-[11px] text-stone-500">
|
||||
5-hour cap: ${teamUsage.fiveHourSpendUsd.toFixed(2)} / $
|
||||
{teamUsage.fiveHourCapUsd.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-[11px] text-stone-500">
|
||||
Cycle ends {new Date(teamUsage.cycleEndsAt).toLocaleDateString('en-US')}
|
||||
</span>
|
||||
</div>
|
||||
{teamUsage.remainingUsd <= 0 && (
|
||||
<p className="text-[11px] text-coral-400 mt-1.5">
|
||||
Included subscription usage is exhausted. Top up credits to continue using AI
|
||||
features without waiting for the next cycle.
|
||||
</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-200 bg-white p-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900 mb-2">Pay-as-You-Go Credits</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-stone-900">
|
||||
${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-stone-900">
|
||||
${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>
|
||||
)}
|
||||
<p className="mb-3 text-[11px] text-stone-500">
|
||||
Subscription usage is consumed first. Top-up credits are reserved for overflow
|
||||
inference, bandwidth, and integration usage.
|
||||
</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>
|
||||
{/* ── Pay as You Go ── PROMOTED TO TOP ─────────────────── */}
|
||||
<PayAsYouGoCard
|
||||
creditBalance={creditBalance}
|
||||
isLoadingCredits={isLoadingCredits}
|
||||
isToppingUp={isToppingUp}
|
||||
onTopUp={handleTopUp}
|
||||
onBalanceRefresh={handleBalanceRefresh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Interval toggle ──────────────────────────────────── */}
|
||||
<div className="flex items-center justify-center gap-2 px-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (paymentMethod !== 'crypto') setBillingInterval('monthly');
|
||||
}}
|
||||
disabled={paymentMethod === 'crypto'}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
billingInterval === 'monthly'
|
||||
? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
|
||||
: 'text-stone-500 hover:text-stone-700'
|
||||
} ${paymentMethod === 'crypto' ? 'opacity-40 cursor-not-allowed' : ''}`}>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingInterval('annual')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
billingInterval === 'annual'
|
||||
? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
|
||||
: 'text-stone-500 hover:text-stone-700'
|
||||
}`}>
|
||||
Annual
|
||||
</button>
|
||||
{/* ── Divider ──────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 max-w-md mx-auto">
|
||||
<div className="flex-1 h-px bg-stone-200" />
|
||||
<span className="text-xs text-stone-400 whitespace-nowrap">
|
||||
Or subscribe for included usage + discounts
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-stone-200" />
|
||||
</div>
|
||||
|
||||
<div className="max-w-md mx-auto">
|
||||
{/* ── Plan tier cards ───────────────────────────────────── */}
|
||||
<div className="space-y-2 px-4">
|
||||
{PLANS.map(plan => {
|
||||
const isCurrent = plan.tier === currentTier;
|
||||
const isUpgrade = checkIsUpgrade(plan.tier, currentTier);
|
||||
const savings = annualSavings(plan, billingInterval);
|
||||
const isThisPurchasing = isPurchasing && purchasingTier === plan.tier;
|
||||
{/* ── Subscription Plans ──────────────────────────────── */}
|
||||
<SubscriptionPlans
|
||||
currentTier={currentTier}
|
||||
billingInterval={billingInterval}
|
||||
setBillingInterval={setBillingInterval}
|
||||
paymentMethod={paymentMethod}
|
||||
setPaymentMethod={setPaymentMethod}
|
||||
isPurchasing={isPurchasing}
|
||||
purchasingTier={purchasingTier}
|
||||
paymentConfirmed={paymentConfirmed}
|
||||
onUpgrade={handleUpgrade}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.tier}
|
||||
className={`rounded-2xl border p-3 transition-all ${
|
||||
isCurrent
|
||||
? 'border-primary-500/40 bg-primary-500/5'
|
||||
: 'border-stone-200 bg-white'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-semibold text-stone-900">{plan.name}</h4>
|
||||
{/* Features inline with title */}
|
||||
{plan.features.map(f => (
|
||||
<span key={f.text} className="text-xs text-stone-600">
|
||||
<span className="text-stone-500 mx-1">•</span>
|
||||
{f.text}
|
||||
</span>
|
||||
))}
|
||||
{isCurrent && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary-500/20 text-primary-400 border border-primary-500/30">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
{savings && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-sage-500/20 text-sage-400 border border-sage-500/30">
|
||||
Save {savings}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold text-stone-900">
|
||||
{displayPrice(plan, billingInterval)}
|
||||
</span>
|
||||
{plan.tier !== 'FREE' && (
|
||||
<span className="text-xs text-stone-400">/mo</span>
|
||||
)}
|
||||
{plan.tier !== 'FREE' && billingInterval === 'annual' && (
|
||||
<span className="text-xs text-stone-500 ml-1">
|
||||
(billed ${plan.annualPrice}/yr)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
Included monthly value: {formatUsdAmount(plan.monthlyBudgetUsd)}
|
||||
</span>
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
7-day cycle: {formatUsdAmount(plan.weeklyBudgetUsd)}
|
||||
</span>
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
5-hour cap: {formatUsdAmount(plan.fiveHourCapUsd)}
|
||||
</span>
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
Discount: {plan.discountPercent}%
|
||||
</span>
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
Storage: {formatStorageLimit(plan.storageLimitBytes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
{isUpgrade && (
|
||||
<button
|
||||
onClick={() => handleUpgrade(plan.tier)}
|
||||
disabled={isPurchasing}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex-shrink-0 ${
|
||||
isPurchasing
|
||||
? 'bg-stone-700/40 text-stone-500 cursor-not-allowed'
|
||||
: 'bg-primary-500 hover:bg-primary-600 text-white'
|
||||
}`}>
|
||||
{isThisPurchasing ? 'Waiting...' : 'Upgrade'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Payment confirmed banner ─────────────────────────── */}
|
||||
{paymentConfirmed && (
|
||||
<div className="rounded-xl bg-sage-500/10 border border-sage-500/20 p-3 mx-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-sage-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-xs text-sage-300 font-medium">
|
||||
Payment confirmed! Your plan has been updated.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Purchasing overlay message ────────────────────────── */}
|
||||
{isPurchasing && (
|
||||
<div className="rounded-xl bg-amber-500/10 border border-amber-500/20 p-3 mx-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-amber-400 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>
|
||||
<p className="text-xs text-amber-700">
|
||||
Waiting for payment confirmation... Complete checkout in the browser window that
|
||||
opened.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Pay with crypto toggle ────────────────────────────── */}
|
||||
<div className="flex items-center justify-between rounded-xl bg-stone-50 border border-stone-200 p-3 mx-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-stone-900">Pay with Crypto</p>
|
||||
<p className="text-[11px] text-stone-400 mt-0.5">
|
||||
You can choose to pay annually using crypto
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPaymentMethod(m => (m === 'card' ? 'crypto' : 'card'))}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors ${
|
||||
paymentMethod === 'crypto' ? 'bg-primary-500' : 'bg-stone-600'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={paymentMethod === 'crypto'}>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
paymentMethod === 'crypto' ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Auto-Recharge Credits ─────────────────────────────── */}
|
||||
{/* ── Inference Budget ────────────────────────────────── */}
|
||||
<div className="px-4 pt-2">
|
||||
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-stone-900">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-200 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-700 bg-amber-50 border border-amber-200 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-100 text-stone-500 border-stone-200 hover:text-stone-700'
|
||||
}`}>
|
||||
${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-100 text-stone-500 border-stone-200 hover:text-stone-700'
|
||||
}`}>
|
||||
${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-100 text-stone-500 border-stone-200 hover:text-stone-700'
|
||||
}`}>
|
||||
${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-200 px-3 py-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-[11px] font-medium text-stone-600">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-50 border border-stone-200 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-50 border border-stone-200 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-stone-900 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-500 hover:text-stone-700 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>
|
||||
<InferenceBudget teamUsage={teamUsage} isLoadingCredits={isLoadingCredits} />
|
||||
</div>
|
||||
|
||||
{/* ── Upgrade benefits ───────────────────────────────────── */}
|
||||
{/* ── Auto-Recharge + Payment Methods ────────────────── */}
|
||||
<AutoRechargeSection
|
||||
arSettings={arSettings}
|
||||
arLoading={arLoading}
|
||||
arError={arError}
|
||||
arSaving={arSaving}
|
||||
arThreshold={arThreshold}
|
||||
arAmount={arAmount}
|
||||
arWeeklyLimit={arWeeklyLimit}
|
||||
arDirty={arDirty}
|
||||
setArThreshold={setArThreshold}
|
||||
setArAmount={setArAmount}
|
||||
setArWeeklyLimit={setArWeeklyLimit}
|
||||
setArError={setArError}
|
||||
onArToggle={handleArToggle}
|
||||
onArSave={handleArSave}
|
||||
cards={cards}
|
||||
cardsLoading={cardsLoading}
|
||||
confirmDeleteId={confirmDeleteId}
|
||||
deletingCardId={deletingCardId}
|
||||
settingDefaultId={settingDefaultId}
|
||||
setConfirmDeleteId={setConfirmDeleteId}
|
||||
onSetDefault={handleSetDefault}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onAddCard={handleAddCard}
|
||||
/>
|
||||
|
||||
{/* ── Why upgrade? ───────────────────────────────────── */}
|
||||
<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">
|
||||
<h3 className="text-sm font-semibold text-stone-900 mb-2">Why upgrade?</h3>
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import type { AutoRechargeSettings, SavedCard } from '../../../../services/api/creditsApi';
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
interface AutoRechargeSectionProps {
|
||||
arSettings: AutoRechargeSettings | null;
|
||||
arLoading: boolean;
|
||||
arError: string | null;
|
||||
arSaving: boolean;
|
||||
arThreshold: number;
|
||||
arAmount: number;
|
||||
arWeeklyLimit: number;
|
||||
arDirty: boolean;
|
||||
setArThreshold: (v: number) => void;
|
||||
setArAmount: (v: number) => void;
|
||||
setArWeeklyLimit: (v: number) => void;
|
||||
setArError: (v: string | null) => void;
|
||||
onArToggle: () => void;
|
||||
onArSave: () => void;
|
||||
// Cards
|
||||
cards: SavedCard[];
|
||||
cardsLoading: boolean;
|
||||
confirmDeleteId: string | null;
|
||||
deletingCardId: string | null;
|
||||
settingDefaultId: string | null;
|
||||
setConfirmDeleteId: (v: string | null) => void;
|
||||
onSetDefault: (paymentMethodId: string) => void;
|
||||
onDeleteCard: (paymentMethodId: string) => void;
|
||||
onAddCard: () => void;
|
||||
}
|
||||
|
||||
const AutoRechargeSection = ({
|
||||
arSettings,
|
||||
arLoading,
|
||||
arError,
|
||||
arSaving,
|
||||
arThreshold,
|
||||
arAmount,
|
||||
arWeeklyLimit,
|
||||
arDirty,
|
||||
setArThreshold,
|
||||
setArAmount,
|
||||
setArWeeklyLimit,
|
||||
setArError,
|
||||
onArToggle,
|
||||
onArSave,
|
||||
cards,
|
||||
cardsLoading,
|
||||
confirmDeleteId,
|
||||
deletingCardId,
|
||||
settingDefaultId,
|
||||
setConfirmDeleteId,
|
||||
onSetDefault,
|
||||
onDeleteCard,
|
||||
onAddCard,
|
||||
}: AutoRechargeSectionProps) => (
|
||||
<div className="px-4 pt-2">
|
||||
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-stone-900">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={onArToggle}
|
||||
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-200 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-700 bg-amber-50 border border-amber-200 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-100 text-stone-500 border-stone-200 hover:text-stone-700'
|
||||
}`}>
|
||||
${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-100 text-stone-500 border-stone-200 hover:text-stone-700'
|
||||
}`}>
|
||||
${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-100 text-stone-500 border-stone-200 hover:text-stone-700'
|
||||
}`}>
|
||||
${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={onArSave}
|
||||
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-200 px-3 py-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-[11px] font-medium text-stone-600">Payment Methods</p>
|
||||
<button
|
||||
onClick={onAddCard}
|
||||
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-50 border border-stone-200 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-50 border border-stone-200 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-stone-900 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={() => onSetDefault(card.id)}
|
||||
disabled={!!settingDefaultId || !!deletingCardId}
|
||||
className="text-[10px] text-stone-500 hover:text-stone-700 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={() => onDeleteCard(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>
|
||||
);
|
||||
|
||||
export default AutoRechargeSection;
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { TeamUsage } from '../../../../services/api/creditsApi';
|
||||
|
||||
interface InferenceBudgetProps {
|
||||
teamUsage: TeamUsage | null;
|
||||
isLoadingCredits: boolean;
|
||||
}
|
||||
|
||||
const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps) => (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-stone-900">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>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span className="text-[11px] text-stone-500">
|
||||
5-hour cap: ${teamUsage.fiveHourSpendUsd.toFixed(2)} / $
|
||||
{teamUsage.fiveHourCapUsd.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-[11px] text-stone-500">
|
||||
Cycle ends {new Date(teamUsage.cycleEndsAt).toLocaleDateString('en-US')}
|
||||
</span>
|
||||
</div>
|
||||
{teamUsage.remainingUsd <= 0 && (
|
||||
<p className="text-[11px] text-coral-400 mt-1.5">
|
||||
Included subscription usage is exhausted. Top up credits to continue using AI features
|
||||
without waiting for the next cycle.
|
||||
</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>
|
||||
);
|
||||
|
||||
export default InferenceBudget;
|
||||
@@ -0,0 +1,200 @@
|
||||
import createDebug from 'debug';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { type CreditBalance, creditsApi } from '../../../../services/api/creditsApi';
|
||||
|
||||
const log = createDebug('openhuman:billing-payg');
|
||||
|
||||
interface PayAsYouGoCardProps {
|
||||
creditBalance: CreditBalance | null;
|
||||
isLoadingCredits: boolean;
|
||||
isToppingUp: boolean;
|
||||
onTopUp: (amountUsd: number) => void;
|
||||
onBalanceRefresh: () => void;
|
||||
}
|
||||
|
||||
const PayAsYouGoCard = ({
|
||||
creditBalance,
|
||||
isLoadingCredits,
|
||||
isToppingUp,
|
||||
onTopUp,
|
||||
onBalanceRefresh,
|
||||
}: PayAsYouGoCardProps) => {
|
||||
// Coupon state (local — no need to share with other sections)
|
||||
const [couponCode, setCouponCode] = useState('');
|
||||
const [couponLoading, setCouponLoading] = useState(false);
|
||||
const [couponError, setCouponError] = useState<string | null>(null);
|
||||
const [couponSuccess, setCouponSuccess] = useState<string | null>(null);
|
||||
|
||||
const handleRedeemCoupon = async () => {
|
||||
const code = couponCode.trim();
|
||||
if (!code || couponLoading) return;
|
||||
|
||||
setCouponLoading(true);
|
||||
setCouponError(null);
|
||||
setCouponSuccess(null);
|
||||
|
||||
try {
|
||||
log('[coupon] redeeming code=%s', code);
|
||||
const result = await creditsApi.redeemCoupon(code);
|
||||
const amount = result?.data?.amountUsd;
|
||||
setCouponSuccess(
|
||||
amount != null
|
||||
? `Coupon redeemed! $${amount.toFixed(2)} added to your credits.`
|
||||
: 'Coupon redeemed successfully!'
|
||||
);
|
||||
setCouponCode('');
|
||||
onBalanceRefresh();
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
? String((err as { error: unknown }).error)
|
||||
: 'Invalid or expired coupon code.';
|
||||
log('[coupon] error: %s', msg);
|
||||
setCouponError(msg);
|
||||
} finally {
|
||||
setCouponLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900 mb-2">Pay as You Go</h3>
|
||||
|
||||
{/* Balance display */}
|
||||
{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-stone-900">
|
||||
${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-stone-900">
|
||||
${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>
|
||||
)}
|
||||
|
||||
<p className="mb-3 text-[11px] text-stone-500">
|
||||
No subscription needed — buy credits as you need them. If you have a subscription, your
|
||||
included budget is consumed first.
|
||||
</p>
|
||||
|
||||
{/* Top-up buttons */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
{[5, 10, 25].map(amount => (
|
||||
<button
|
||||
key={amount}
|
||||
onClick={() => onTopUp(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>
|
||||
|
||||
{/* Coupon redemption */}
|
||||
<div className="border-t border-stone-100 pt-3">
|
||||
<p className="text-[11px] text-stone-400 mb-1.5">Have a coupon?</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={couponCode}
|
||||
onChange={e => {
|
||||
setCouponCode(e.target.value.toUpperCase());
|
||||
if (couponError) setCouponError(null);
|
||||
if (couponSuccess) setCouponSuccess(null);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleRedeemCoupon();
|
||||
}}
|
||||
placeholder="XXXX-XXXX"
|
||||
className="flex-1 px-2.5 py-1.5 text-xs rounded-lg border border-stone-200 bg-stone-50 text-stone-900 placeholder-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleRedeemCoupon}
|
||||
disabled={couponLoading || !couponCode.trim()}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{couponLoading ? '…' : 'Redeem'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{couponSuccess && (
|
||||
<div className="mt-2 flex items-center gap-1.5 rounded-lg bg-sage-500/10 border border-sage-500/20 px-2.5 py-1.5">
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-sage-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-[11px] text-sage-300 font-medium">{couponSuccess}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{couponError && (
|
||||
<div className="mt-2 flex items-center gap-1.5 rounded-lg bg-coral-500/10 border border-coral-500/20 px-2.5 py-1.5">
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-coral-400 flex-shrink-0"
|
||||
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">{couponError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PayAsYouGoCard;
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { PlanTier } from '../../../../types/api';
|
||||
import {
|
||||
annualSavings,
|
||||
isUpgrade as checkIsUpgrade,
|
||||
displayPrice,
|
||||
formatStorageLimit,
|
||||
formatUsdAmount,
|
||||
PLANS,
|
||||
} from '../billingHelpers';
|
||||
|
||||
interface SubscriptionPlansProps {
|
||||
currentTier: PlanTier;
|
||||
billingInterval: 'monthly' | 'annual';
|
||||
setBillingInterval: (v: 'monthly' | 'annual') => void;
|
||||
paymentMethod: 'card' | 'crypto';
|
||||
setPaymentMethod: (v: 'card' | 'crypto') => void;
|
||||
isPurchasing: boolean;
|
||||
purchasingTier: PlanTier | null;
|
||||
paymentConfirmed: boolean;
|
||||
onUpgrade: (tier: PlanTier) => void;
|
||||
}
|
||||
|
||||
const SubscriptionPlans = ({
|
||||
currentTier,
|
||||
billingInterval,
|
||||
setBillingInterval,
|
||||
paymentMethod,
|
||||
setPaymentMethod,
|
||||
isPurchasing,
|
||||
purchasingTier,
|
||||
paymentConfirmed,
|
||||
onUpgrade,
|
||||
}: SubscriptionPlansProps) => (
|
||||
<>
|
||||
{/* Interval toggle */}
|
||||
<div className="flex items-center justify-center gap-2 px-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (paymentMethod !== 'crypto') setBillingInterval('monthly');
|
||||
}}
|
||||
disabled={paymentMethod === 'crypto'}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
billingInterval === 'monthly'
|
||||
? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
|
||||
: 'text-stone-500 hover:text-stone-700'
|
||||
} ${paymentMethod === 'crypto' ? 'opacity-40 cursor-not-allowed' : ''}`}>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingInterval('annual')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
billingInterval === 'annual'
|
||||
? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
|
||||
: 'text-stone-500 hover:text-stone-700'
|
||||
}`}>
|
||||
Annual
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Plan tier cards */}
|
||||
<div className="space-y-2 px-4">
|
||||
{PLANS.map(plan => {
|
||||
const isCurrent = plan.tier === currentTier;
|
||||
const isUpgrade = checkIsUpgrade(plan.tier, currentTier);
|
||||
const savings = annualSavings(plan, billingInterval);
|
||||
const isThisPurchasing = isPurchasing && purchasingTier === plan.tier;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.tier}
|
||||
className={`rounded-2xl border p-3 transition-all ${
|
||||
isCurrent ? 'border-primary-500/40 bg-primary-500/5' : 'border-stone-200 bg-white'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-semibold text-stone-900">{plan.name}</h4>
|
||||
{plan.features.map(f => (
|
||||
<span key={f.text} className="text-xs text-stone-600">
|
||||
<span className="text-stone-500 mx-1">•</span>
|
||||
{f.text}
|
||||
</span>
|
||||
))}
|
||||
{isCurrent && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary-500/20 text-primary-400 border border-primary-500/30">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
{savings && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-sage-500/20 text-sage-400 border border-sage-500/30">
|
||||
Save {savings}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold text-stone-900">
|
||||
{displayPrice(plan, billingInterval)}
|
||||
</span>
|
||||
{plan.tier !== 'FREE' && <span className="text-xs text-stone-400">/mo</span>}
|
||||
{plan.tier !== 'FREE' && billingInterval === 'annual' && (
|
||||
<span className="text-xs text-stone-500 ml-1">
|
||||
(billed ${plan.annualPrice}/yr)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
Included monthly value: {formatUsdAmount(plan.monthlyBudgetUsd)}
|
||||
</span>
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
7-day cycle: {formatUsdAmount(plan.weeklyBudgetUsd)}
|
||||
</span>
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
5-hour cap: {formatUsdAmount(plan.fiveHourCapUsd)}
|
||||
</span>
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
Discount: {plan.discountPercent}%
|
||||
</span>
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
Storage: {formatStorageLimit(plan.storageLimitBytes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
{isUpgrade && (
|
||||
<button
|
||||
onClick={() => onUpgrade(plan.tier)}
|
||||
disabled={isPurchasing}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex-shrink-0 ${
|
||||
isPurchasing
|
||||
? 'bg-stone-700/40 text-stone-500 cursor-not-allowed'
|
||||
: 'bg-primary-500 hover:bg-primary-600 text-white'
|
||||
}`}>
|
||||
{isThisPurchasing ? 'Waiting...' : 'Upgrade'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Payment confirmed banner */}
|
||||
{paymentConfirmed && (
|
||||
<div className="rounded-xl bg-sage-500/10 border border-sage-500/20 p-3 mx-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-sage-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<p className="text-xs text-sage-300 font-medium">
|
||||
Payment confirmed! Your plan has been updated.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Purchasing overlay message */}
|
||||
{isPurchasing && (
|
||||
<div className="rounded-xl bg-amber-500/10 border border-amber-500/20 p-3 mx-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-amber-400 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>
|
||||
<p className="text-xs text-amber-700">
|
||||
Waiting for payment confirmation... Complete checkout in the browser window that opened.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pay with crypto toggle */}
|
||||
<div className="flex items-center justify-between rounded-xl bg-stone-50 border border-stone-200 p-3 mx-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-stone-900">Pay with Crypto</p>
|
||||
<p className="text-[11px] text-stone-400 mt-0.5">
|
||||
You can choose to pay annually using crypto
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPaymentMethod(paymentMethod === 'card' ? 'crypto' : 'card')}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors ${
|
||||
paymentMethod === 'crypto' ? 'bg-primary-500' : 'bg-stone-600'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={paymentMethod === 'crypto'}>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
paymentMethod === 'crypto' ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default SubscriptionPlans;
|
||||
@@ -110,6 +110,21 @@ export interface UpdateCardPayload {
|
||||
billingDetails?: CardBillingDetails;
|
||||
}
|
||||
|
||||
// ── Coupon types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CouponRedeemResult {
|
||||
success: boolean;
|
||||
data: { code: string; amountUsd: number };
|
||||
}
|
||||
|
||||
export interface RedeemedCoupon {
|
||||
code: string;
|
||||
amountUsd: number;
|
||||
redeemedAt: string;
|
||||
activationType: string;
|
||||
fulfilled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits API endpoints
|
||||
*/
|
||||
@@ -207,4 +222,22 @@ export const creditsApi = {
|
||||
deleteCard: async (paymentMethodId: string): Promise<CardsData> => {
|
||||
return await callCoreCommand<CardsData>('openhuman.billing_delete_card', { paymentMethodId });
|
||||
},
|
||||
|
||||
// ── Coupons ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Redeem a coupon code to add credits.
|
||||
* POST /coupons/redeem
|
||||
*/
|
||||
redeemCoupon: async (code: string): Promise<CouponRedeemResult> => {
|
||||
return await callCoreCommand<CouponRedeemResult>('openhuman.billing_redeem_coupon', { code });
|
||||
},
|
||||
|
||||
/**
|
||||
* List coupons redeemed by the current user.
|
||||
* GET /coupons/me
|
||||
*/
|
||||
getUserCoupons: async (): Promise<RedeemedCoupon[]> => {
|
||||
return await callCoreCommand<RedeemedCoupon[]>('openhuman.billing_get_coupons');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -285,6 +285,34 @@ pub async fn create_coinbase_charge(
|
||||
))
|
||||
}
|
||||
|
||||
// ── Coupon operations ──────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RedeemCouponBody<'a> {
|
||||
code: &'a str,
|
||||
}
|
||||
|
||||
/// Redeem a coupon code to add credits to the user's account.
|
||||
/// Maps to `POST /coupons/redeem`.
|
||||
pub async fn redeem_coupon(config: &Config, code: &str) -> Result<RpcOutcome<Value>, String> {
|
||||
let code = code.trim();
|
||||
if code.is_empty() {
|
||||
return Err("code is required".to_string());
|
||||
}
|
||||
|
||||
let body = json!(RedeemCouponBody { code });
|
||||
let data = get_authed_value(config, Method::POST, "/coupons/redeem", Some(body)).await?;
|
||||
|
||||
Ok(RpcOutcome::single_log(data, "coupon redeemed"))
|
||||
}
|
||||
|
||||
/// List coupons redeemed by the current user.
|
||||
/// Maps to `GET /coupons/me`.
|
||||
pub async fn get_user_coupons(config: &Config) -> Result<RpcOutcome<Value>, String> {
|
||||
let data = get_authed_value(config, Method::GET, "/coupons/me", None).await?;
|
||||
Ok(RpcOutcome::single_log(data, "user coupons fetched"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -57,6 +57,12 @@ struct UpdateCardParams {
|
||||
payload: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RedeemCouponParams {
|
||||
code: String,
|
||||
}
|
||||
|
||||
pub fn all_billing_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
billing_schemas("billing_get_current_plan"),
|
||||
@@ -72,6 +78,8 @@ pub fn all_billing_controller_schemas() -> Vec<ControllerSchema> {
|
||||
billing_schemas("billing_create_setup_intent"),
|
||||
billing_schemas("billing_update_card"),
|
||||
billing_schemas("billing_delete_card"),
|
||||
billing_schemas("billing_redeem_coupon"),
|
||||
billing_schemas("billing_get_coupons"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -129,6 +137,14 @@ pub fn all_billing_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: billing_schemas("billing_delete_card"),
|
||||
handler: handle_billing_delete_card,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: billing_schemas("billing_redeem_coupon"),
|
||||
handler: handle_billing_redeem_coupon,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: billing_schemas("billing_get_coupons"),
|
||||
handler: handle_billing_get_coupons,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -327,6 +343,26 @@ pub fn billing_schemas(function: &str) -> ControllerSchema {
|
||||
)],
|
||||
outputs: vec![json_output("cards", "Updated saved cards payload.")],
|
||||
},
|
||||
"billing_redeem_coupon" => ControllerSchema {
|
||||
namespace: "billing",
|
||||
function: "redeem_coupon",
|
||||
description: "Redeem a coupon code to add credits to the account.",
|
||||
inputs: vec![required_string("code", "Coupon code to redeem.")],
|
||||
outputs: vec![json_output(
|
||||
"result",
|
||||
"Coupon redemption result from /coupons/redeem.",
|
||||
)],
|
||||
},
|
||||
"billing_get_coupons" => ControllerSchema {
|
||||
namespace: "billing",
|
||||
function: "get_coupons",
|
||||
description: "List coupons redeemed by the current user.",
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output(
|
||||
"coupons",
|
||||
"User's redeemed coupons from /coupons/me.",
|
||||
)],
|
||||
},
|
||||
_ => ControllerSchema {
|
||||
namespace: "billing",
|
||||
function: "unknown",
|
||||
@@ -467,6 +503,21 @@ fn handle_billing_delete_card(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_billing_redeem_coupon(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let payload = deserialize_params::<RedeemCouponParams>(params)?;
|
||||
to_json(crate::openhuman::billing::redeem_coupon(&config, payload.code.trim()).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_billing_get_coupons(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
to_json(crate::openhuman::billing::get_user_coupons(&config).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn to_json(outcome: RpcOutcome<Value>) -> Result<Value, String> {
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user