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:
Steven Enamakel
2026-04-05 22:06:31 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 7bce9d2bbe
commit 09b627ccc9
8 changed files with 1069 additions and 693 deletions
@@ -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">&bull;</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;
+33
View File
@@ -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');
},
};
+28
View File
@@ -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::*;
+51
View File
@@ -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()
}