Phase 2, 2.1 and 2.2 complete
This commit is contained in:
208
frontend/src/components/dashboard/AccountInfoWidget.tsx
Normal file
208
frontend/src/components/dashboard/AccountInfoWidget.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Account Info Widget
|
||||
* Displays account-related billing information:
|
||||
* - Credits consumed/remaining this billing period
|
||||
* - Reset day (when credits refresh)
|
||||
* - Last payment date
|
||||
* - Next payment due date
|
||||
* - Current plan/package type
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ComponentCard from '../common/ComponentCard';
|
||||
import Button from '../ui/button/Button';
|
||||
import Badge from '../ui/badge/Badge';
|
||||
import { CreditBalance, Subscription, Plan } from '../../services/billing.api';
|
||||
import {
|
||||
CalendarIcon,
|
||||
CreditCardIcon,
|
||||
UserCircleIcon,
|
||||
ArrowRightIcon,
|
||||
} from '../../icons';
|
||||
|
||||
interface AccountInfoWidgetProps {
|
||||
balance: CreditBalance | null;
|
||||
subscription?: Subscription | null;
|
||||
plan?: Plan | null;
|
||||
userPlan?: any; // Plan from user.account.plan
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// Helper to format dates
|
||||
function formatDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '—';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get days until date
|
||||
function getDaysUntil(dateStr: string | undefined): number | null {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function AccountInfoWidget({
|
||||
balance,
|
||||
subscription,
|
||||
plan,
|
||||
userPlan,
|
||||
loading = false,
|
||||
}: AccountInfoWidgetProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ComponentCard title="Account Info" desc="Billing & subscription">
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-12 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
);
|
||||
}
|
||||
|
||||
const periodEnd = subscription?.current_period_end;
|
||||
const daysUntilReset = getDaysUntil(periodEnd);
|
||||
|
||||
// Resolve plan - prefer explicit plan, then subscription plan, then userPlan
|
||||
const currentPlan = plan || (subscription?.plan && typeof subscription.plan === 'object' ? subscription.plan : null) || userPlan;
|
||||
|
||||
return (
|
||||
<ComponentCard
|
||||
title="Account Info"
|
||||
desc="Billing & subscription"
|
||||
headerContent={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/account/settings')}
|
||||
endIcon={<ArrowRightIcon className="w-3 h-3" />}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Current Plan */}
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-brand-50 dark:bg-brand-900/20 border border-brand-100 dark:border-brand-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center text-brand-600 dark:text-brand-400">
|
||||
<UserCircleIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-brand-600 dark:text-brand-400 uppercase font-medium tracking-wide">
|
||||
Current Plan
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-brand-900 dark:text-brand-100">
|
||||
{currentPlan?.name || 'Free Plan'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{subscription?.status && (
|
||||
<Badge
|
||||
variant="soft"
|
||||
color={subscription.status === 'active' ? 'success' : 'warning'}
|
||||
size="sm"
|
||||
>
|
||||
{subscription.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Credits This Period */}
|
||||
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">
|
||||
Used This Period
|
||||
</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{balance?.credits_used_this_month?.toLocaleString() || '0'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
of {balance?.plan_credits_per_month?.toLocaleString() || '0'} credits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Credits Remaining */}
|
||||
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">
|
||||
Remaining
|
||||
</p>
|
||||
<p className="text-xl font-bold text-success-600 dark:text-success-400">
|
||||
{balance?.credits_remaining?.toLocaleString() || '0'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
credits available
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reset Date */}
|
||||
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CalendarIcon className="w-4 h-4 text-gray-400" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Credits Reset
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{formatDate(periodEnd)}
|
||||
</p>
|
||||
{daysUntilReset !== null && daysUntilReset > 0 && (
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
in {daysUntilReset} day{daysUntilReset !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next Payment */}
|
||||
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CreditCardIcon className="w-4 h-4 text-gray-400" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Next Payment
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{formatDate(periodEnd)}
|
||||
</p>
|
||||
{currentPlan?.price && (
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
${currentPlan.price}/mo
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upgrade CTA (if on free or lower plan) */}
|
||||
{(!currentPlan || currentPlan.slug === 'free' || currentPlan.slug === 'starter') && (
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
size="sm"
|
||||
onClick={() => navigate('/account/upgrade')}
|
||||
className="w-full"
|
||||
>
|
||||
Upgrade Plan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user