242 lines
7.8 KiB
TypeScript
242 lines
7.8 KiB
TypeScript
|
|
import React from 'react';
|
||
|
|
import { useTranslation } from 'react-i18next';
|
||
|
|
import { AlertTriangle, TrendingUp, ArrowUpRight, Infinity } from 'lucide-react';
|
||
|
|
import { Card, Button } from '../ui';
|
||
|
|
import type { SubscriptionTier } from '../../api';
|
||
|
|
|
||
|
|
interface UsageMetricCardProps {
|
||
|
|
metric: string;
|
||
|
|
label: string;
|
||
|
|
current: number;
|
||
|
|
limit: number | null; // null = unlimited
|
||
|
|
unit?: string;
|
||
|
|
trend?: number[]; // 30-day history
|
||
|
|
predictedBreachDate?: string | null;
|
||
|
|
daysUntilBreach?: number | null;
|
||
|
|
currentTier: SubscriptionTier;
|
||
|
|
upgradeTier?: SubscriptionTier;
|
||
|
|
upgradeLimit?: number | null;
|
||
|
|
onUpgrade?: () => void;
|
||
|
|
icon?: React.ReactNode;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const UsageMetricCard: React.FC<UsageMetricCardProps> = ({
|
||
|
|
metric,
|
||
|
|
label,
|
||
|
|
current,
|
||
|
|
limit,
|
||
|
|
unit = '',
|
||
|
|
trend,
|
||
|
|
predictedBreachDate,
|
||
|
|
daysUntilBreach,
|
||
|
|
currentTier,
|
||
|
|
upgradeTier = 'professional',
|
||
|
|
upgradeLimit,
|
||
|
|
onUpgrade,
|
||
|
|
icon
|
||
|
|
}) => {
|
||
|
|
const { t } = useTranslation('subscription');
|
||
|
|
|
||
|
|
// Calculate percentage
|
||
|
|
const percentage = limit ? Math.min((current / limit) * 100, 100) : 0;
|
||
|
|
const isUnlimited = limit === null || limit === -1;
|
||
|
|
|
||
|
|
// Determine status color
|
||
|
|
const getStatusColor = () => {
|
||
|
|
if (isUnlimited) return 'green';
|
||
|
|
if (percentage >= 90) return 'red';
|
||
|
|
if (percentage >= 80) return 'yellow';
|
||
|
|
return 'green';
|
||
|
|
};
|
||
|
|
|
||
|
|
const statusColor = getStatusColor();
|
||
|
|
|
||
|
|
// Color classes
|
||
|
|
const colorClasses = {
|
||
|
|
green: {
|
||
|
|
bg: 'bg-green-500',
|
||
|
|
text: 'text-green-600 dark:text-green-400',
|
||
|
|
border: 'border-green-500',
|
||
|
|
bgLight: 'bg-green-50 dark:bg-green-900/20',
|
||
|
|
ring: 'ring-green-500/20'
|
||
|
|
},
|
||
|
|
yellow: {
|
||
|
|
bg: 'bg-yellow-500',
|
||
|
|
text: 'text-yellow-600 dark:text-yellow-400',
|
||
|
|
border: 'border-yellow-500',
|
||
|
|
bgLight: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||
|
|
ring: 'ring-yellow-500/20'
|
||
|
|
},
|
||
|
|
red: {
|
||
|
|
bg: 'bg-red-500',
|
||
|
|
text: 'text-red-600 dark:text-red-400',
|
||
|
|
border: 'border-red-500',
|
||
|
|
bgLight: 'bg-red-50 dark:bg-red-900/20',
|
||
|
|
ring: 'ring-red-500/20'
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const colors = colorClasses[statusColor];
|
||
|
|
|
||
|
|
// Format display value
|
||
|
|
const formatValue = (value: number | null) => {
|
||
|
|
if (value === null || value === -1) return t('limits.unlimited');
|
||
|
|
return `${value.toLocaleString()}${unit}`;
|
||
|
|
};
|
||
|
|
|
||
|
|
// Render trend sparkline
|
||
|
|
const renderSparkline = () => {
|
||
|
|
if (!trend || trend.length === 0) return null;
|
||
|
|
|
||
|
|
const max = Math.max(...trend, current);
|
||
|
|
const min = Math.min(...trend, 0);
|
||
|
|
const range = max - min || 1;
|
||
|
|
|
||
|
|
const points = trend.map((value, index) => {
|
||
|
|
const x = (index / (trend.length - 1)) * 100;
|
||
|
|
const y = 100 - ((value - min) / range) * 100;
|
||
|
|
return `${x},${y}`;
|
||
|
|
}).join(' ');
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mt-2 h-8 relative">
|
||
|
|
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||
|
|
<polyline
|
||
|
|
points={points}
|
||
|
|
fill="none"
|
||
|
|
stroke="currentColor"
|
||
|
|
strokeWidth="2"
|
||
|
|
className={colors.text}
|
||
|
|
opacity="0.5"
|
||
|
|
/>
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Card className={`p-4 transition-all duration-200 ${
|
||
|
|
statusColor === 'red' ? `ring-2 ${colors.ring}` : ''
|
||
|
|
}`}>
|
||
|
|
{/* Header */}
|
||
|
|
<div className="flex items-start justify-between mb-3">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
{icon && <div className="text-[var(--text-secondary)]">{icon}</div>}
|
||
|
|
<div>
|
||
|
|
<h3 className="text-sm font-semibold text-[var(--text-primary)]">
|
||
|
|
{label}
|
||
|
|
</h3>
|
||
|
|
<p className="text-xs text-[var(--text-secondary)] mt-0.5">
|
||
|
|
{currentTier.charAt(0).toUpperCase() + currentTier.slice(1)} tier
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Status Badge */}
|
||
|
|
{!isUnlimited && (
|
||
|
|
<div className={`px-2 py-1 rounded-full text-xs font-bold ${colors.bgLight} ${colors.text}`}>
|
||
|
|
{Math.round(percentage)}%
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Usage Display */}
|
||
|
|
<div className="mb-3">
|
||
|
|
<div className="flex items-baseline justify-between mb-2">
|
||
|
|
<span className="text-2xl font-bold text-[var(--text-primary)]">
|
||
|
|
{formatValue(current)}
|
||
|
|
</span>
|
||
|
|
<span className="text-sm text-[var(--text-secondary)]">
|
||
|
|
/ {formatValue(limit)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Progress Bar */}
|
||
|
|
{!isUnlimited && (
|
||
|
|
<div className="w-full h-2 bg-[var(--bg-secondary)] rounded-full overflow-hidden">
|
||
|
|
<div
|
||
|
|
className={`h-full transition-all duration-300 ${colors.bg} ${
|
||
|
|
statusColor === 'red' ? 'animate-pulse' : ''
|
||
|
|
}`}
|
||
|
|
style={{ width: `${percentage}%` }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Trend Sparkline */}
|
||
|
|
{trend && trend.length > 0 && (
|
||
|
|
<div className="mb-3">
|
||
|
|
<div className="flex items-center gap-1 mb-1">
|
||
|
|
<TrendingUp className="w-3 h-3 text-[var(--text-secondary)]" />
|
||
|
|
<span className="text-xs text-[var(--text-secondary)]">30-day trend</span>
|
||
|
|
</div>
|
||
|
|
{renderSparkline()}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Warning Message */}
|
||
|
|
{!isUnlimited && percentage >= 80 && (
|
||
|
|
<div className={`mb-3 p-3 rounded-lg ${colors.bgLight} border ${colors.border}`}>
|
||
|
|
<div className="flex items-start gap-2">
|
||
|
|
<AlertTriangle className={`w-4 h-4 ${colors.text} flex-shrink-0 mt-0.5`} />
|
||
|
|
<div className="flex-1">
|
||
|
|
{daysUntilBreach !== null && daysUntilBreach !== undefined && daysUntilBreach > 0 ? (
|
||
|
|
<p className={`text-xs ${colors.text} font-medium`}>
|
||
|
|
You'll hit your limit in ~{daysUntilBreach} days
|
||
|
|
</p>
|
||
|
|
) : percentage >= 100 ? (
|
||
|
|
<p className={`text-xs ${colors.text} font-medium`}>
|
||
|
|
You've reached your limit
|
||
|
|
</p>
|
||
|
|
) : (
|
||
|
|
<p className={`text-xs ${colors.text} font-medium`}>
|
||
|
|
You're approaching your limit
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Upgrade CTA */}
|
||
|
|
{!isUnlimited && percentage >= 80 && upgradeTier && onUpgrade && (
|
||
|
|
<div className="mt-3 pt-3 border-t border-[var(--border-primary)]">
|
||
|
|
<div className="flex items-center justify-between text-xs text-[var(--text-secondary)] mb-2">
|
||
|
|
<span>Upgrade to {upgradeTier.charAt(0).toUpperCase() + upgradeTier.slice(1)}</span>
|
||
|
|
<span className="font-bold text-[var(--text-primary)]">
|
||
|
|
{formatValue(upgradeLimit)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
onClick={onUpgrade}
|
||
|
|
variant="primary"
|
||
|
|
className="w-full py-2 text-sm font-semibold flex items-center justify-center gap-2 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800"
|
||
|
|
>
|
||
|
|
<span>Upgrade Now</span>
|
||
|
|
<ArrowUpRight className="w-4 h-4" />
|
||
|
|
</Button>
|
||
|
|
{upgradeTier === 'professional' && (
|
||
|
|
<p className="text-xs text-center text-[var(--text-secondary)] mt-2">
|
||
|
|
{upgradeLimit === null || upgradeLimit === -1
|
||
|
|
? 'Get unlimited capacity'
|
||
|
|
: `${((upgradeLimit || 0) / (limit || 1) - 1) * 100}x more capacity`
|
||
|
|
}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Unlimited Badge */}
|
||
|
|
{isUnlimited && (
|
||
|
|
<div className="mt-3 p-3 rounded-lg bg-gradient-to-r from-emerald-50 to-green-50 dark:from-emerald-900/20 dark:to-green-900/20 border-2 border-emerald-400/40">
|
||
|
|
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-400 text-center flex items-center justify-center gap-2">
|
||
|
|
<Infinity className="w-4 h-4" />
|
||
|
|
Unlimited
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
};
|