Phase 3 - credts, usage, plans app pages #Migrations
This commit is contained in:
@@ -61,11 +61,12 @@ const Credits = lazy(() => import("./pages/Billing/Credits"));
|
||||
const Transactions = lazy(() => import("./pages/Billing/Transactions"));
|
||||
const Usage = lazy(() => import("./pages/Billing/Usage"));
|
||||
const CreditsAndBilling = lazy(() => import("./pages/Settings/CreditsAndBilling"));
|
||||
const PurchaseCreditsPage = lazy(() => import("./pages/account/PurchaseCreditsPage"));
|
||||
const PlansAndBillingPage = lazy(() => import("./pages/account/PlansAndBillingPage"));
|
||||
const AccountSettingsPage = lazy(() => import("./pages/account/AccountSettingsPage"));
|
||||
// TeamManagementPage - Now integrated as tab in AccountSettingsPage
|
||||
const UsageAnalyticsPage = lazy(() => import("./pages/account/UsageAnalyticsPage"));
|
||||
const UsageDashboardPage = lazy(() => import("./pages/account/UsageDashboardPage"));
|
||||
const UsageLogsPage = lazy(() => import("./pages/account/UsageLogsPage"));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/account/ContentSettingsPage"));
|
||||
const NotificationsPage = lazy(() => import("./pages/account/NotificationsPage"));
|
||||
|
||||
@@ -221,12 +222,16 @@ export default function App() {
|
||||
<Route path="/account/plans" element={<PlansAndBillingPage />} />
|
||||
<Route path="/account/plans/upgrade" element={<PlansAndBillingPage />} />
|
||||
<Route path="/account/plans/history" element={<PlansAndBillingPage />} />
|
||||
<Route path="/account/purchase-credits" element={<PurchaseCreditsPage />} />
|
||||
<Route path="/account/purchase-credits" element={<Navigate to="/account/plans" replace />} />
|
||||
|
||||
{/* Usage - with sub-routes for sidebar navigation */}
|
||||
<Route path="/account/usage" element={<UsageAnalyticsPage />} />
|
||||
<Route path="/account/usage/credits" element={<UsageAnalyticsPage />} />
|
||||
<Route path="/account/usage/activity" element={<UsageAnalyticsPage />} />
|
||||
{/* Usage Dashboard - Single comprehensive page */}
|
||||
<Route path="/account/usage" element={<UsageDashboardPage />} />
|
||||
{/* Usage Logs - Detailed operation history */}
|
||||
<Route path="/account/usage/logs" element={<UsageLogsPage />} />
|
||||
{/* Legacy routes redirect to dashboard */}
|
||||
<Route path="/account/usage/credits" element={<UsageDashboardPage />} />
|
||||
<Route path="/account/usage/insights" element={<UsageDashboardPage />} />
|
||||
<Route path="/account/usage/activity" element={<UsageDashboardPage />} />
|
||||
|
||||
{/* Content Settings - with sub-routes for sidebar navigation */}
|
||||
<Route path="/account/content-settings" element={<ContentSettingsPage />} />
|
||||
|
||||
@@ -22,7 +22,6 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
|
||||
const PLAN_ALLOWED_PATHS = [
|
||||
'/account/plans',
|
||||
'/account/purchase-credits',
|
||||
'/account/settings',
|
||||
'/account/team',
|
||||
'/account/usage',
|
||||
|
||||
@@ -24,7 +24,7 @@ interface Plan {
|
||||
max_users: number;
|
||||
max_sites: number;
|
||||
max_keywords: number;
|
||||
monthly_word_count_limit: number;
|
||||
max_ahrefs_queries: number;
|
||||
included_credits: number;
|
||||
features: string[];
|
||||
}
|
||||
@@ -260,8 +260,7 @@ export default function SignUpFormUnified({
|
||||
features.push(`${plan.max_sites} ${plan.max_sites === 1 ? 'Site' : 'Sites'}`);
|
||||
features.push(`${plan.max_users} ${plan.max_users === 1 ? 'User' : 'Users'}`);
|
||||
features.push(`${formatNumber(plan.max_keywords || 0)} Keywords`);
|
||||
features.push(`${formatNumber(plan.monthly_word_count_limit || 0)} Words/Month`);
|
||||
features.push(`${formatNumber(plan.included_credits || 0)} AI Credits`);
|
||||
features.push(`${formatNumber(plan.included_credits || 0)} Credits/Month`);
|
||||
return features;
|
||||
};
|
||||
|
||||
|
||||
450
frontend/src/components/billing/CreditInsightsCharts.tsx
Normal file
450
frontend/src/components/billing/CreditInsightsCharts.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Credit Insights Charts Component
|
||||
* Displays credit usage analytics with visual charts
|
||||
* - Donut chart: Credits by operation type
|
||||
* - Line chart: Daily credit usage timeline
|
||||
* - Bar chart: Top credit-consuming operations
|
||||
*/
|
||||
|
||||
import Chart from 'react-apexcharts';
|
||||
import { ApexOptions } from 'apexcharts';
|
||||
import { Card } from '../ui/card';
|
||||
import { ActivityIcon, TrendingUpIcon, PieChartIcon, BarChart3Icon } from '../../icons';
|
||||
import type { UsageAnalytics } from '../../services/billing.api';
|
||||
|
||||
interface CreditInsightsChartsProps {
|
||||
analytics: UsageAnalytics | null;
|
||||
loading?: boolean;
|
||||
period: number;
|
||||
}
|
||||
|
||||
// Friendly names for operation types
|
||||
const OPERATION_LABELS: Record<string, string> = {
|
||||
content_generation: 'Content Generation',
|
||||
image_generation: 'Image Generation',
|
||||
keyword_clustering: 'Keyword Clustering',
|
||||
content_analysis: 'Content Analysis',
|
||||
subscription: 'Subscription',
|
||||
purchase: 'Credit Purchase',
|
||||
refund: 'Refund',
|
||||
adjustment: 'Adjustment',
|
||||
grant: 'Credit Grant',
|
||||
deduction: 'Deduction',
|
||||
};
|
||||
|
||||
const CHART_COLORS = [
|
||||
'var(--color-brand-500)',
|
||||
'var(--color-purple-500)',
|
||||
'var(--color-success-500)',
|
||||
'var(--color-warning-500)',
|
||||
'var(--color-error-500)',
|
||||
'var(--color-info-500)',
|
||||
'#6366f1', // indigo
|
||||
'#ec4899', // pink
|
||||
'#14b8a6', // teal
|
||||
'#f97316', // orange
|
||||
];
|
||||
|
||||
export default function CreditInsightsCharts({ analytics, loading, period }: CreditInsightsChartsProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i} className="p-6 animate-pulse">
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-4"></div>
|
||||
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!analytics) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<PieChartIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p>No analytics data available</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare data for donut chart (credits by operation type)
|
||||
const usageByType = analytics.usage_by_type.filter(item => Math.abs(item.total) > 0);
|
||||
const donutLabels = usageByType.map(item => OPERATION_LABELS[item.transaction_type] || item.transaction_type.replace(/_/g, ' '));
|
||||
const donutSeries = usageByType.map(item => Math.abs(item.total));
|
||||
|
||||
const donutOptions: ApexOptions = {
|
||||
chart: {
|
||||
type: 'donut',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
},
|
||||
labels: donutLabels,
|
||||
colors: CHART_COLORS.slice(0, donutLabels.length),
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
fontFamily: 'Outfit',
|
||||
labels: {
|
||||
colors: 'var(--color-gray-600)',
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
size: '65%',
|
||||
labels: {
|
||||
show: true,
|
||||
name: {
|
||||
show: true,
|
||||
fontSize: '14px',
|
||||
fontFamily: 'Outfit',
|
||||
color: 'var(--color-gray-600)',
|
||||
},
|
||||
value: {
|
||||
show: true,
|
||||
fontSize: '24px',
|
||||
fontFamily: 'Outfit',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-gray-900)',
|
||||
formatter: (val: string) => parseInt(val).toLocaleString(),
|
||||
},
|
||||
total: {
|
||||
show: true,
|
||||
label: 'Total Credits',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'Outfit',
|
||||
color: 'var(--color-gray-600)',
|
||||
formatter: () => analytics.total_usage.toLocaleString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
tooltip: {
|
||||
y: {
|
||||
formatter: (val: number) => `${val.toLocaleString()} credits`,
|
||||
},
|
||||
},
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 480,
|
||||
options: {
|
||||
chart: {
|
||||
width: 300,
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Prepare data for timeline chart (daily usage)
|
||||
const dailyData = analytics.daily_usage || [];
|
||||
const timelineCategories = dailyData.map(d => {
|
||||
const date = new Date(d.date);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
});
|
||||
const usageSeries = dailyData.map(d => Math.abs(d.usage));
|
||||
const purchasesSeries = dailyData.map(d => d.purchases);
|
||||
|
||||
const timelineOptions: ApexOptions = {
|
||||
chart: {
|
||||
type: 'area',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
height: 300,
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
zoom: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
colors: ['var(--color-brand-500)', 'var(--color-success-500)'],
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 2,
|
||||
},
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: {
|
||||
shadeIntensity: 1,
|
||||
opacityFrom: 0.4,
|
||||
opacityTo: 0.1,
|
||||
stops: [0, 90, 100],
|
||||
},
|
||||
},
|
||||
xaxis: {
|
||||
categories: timelineCategories,
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
labels: {
|
||||
style: {
|
||||
colors: 'var(--color-gray-500)',
|
||||
fontFamily: 'Outfit',
|
||||
},
|
||||
rotate: -45,
|
||||
rotateAlways: dailyData.length > 14,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
colors: 'var(--color-gray-500)',
|
||||
fontFamily: 'Outfit',
|
||||
},
|
||||
formatter: (val: number) => val.toLocaleString(),
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: 'var(--color-gray-200)',
|
||||
strokeDashArray: 4,
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
horizontalAlign: 'right',
|
||||
fontFamily: 'Outfit',
|
||||
labels: {
|
||||
colors: 'var(--color-gray-600)',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
y: {
|
||||
formatter: (val: number) => `${val.toLocaleString()} credits`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const timelineSeries = [
|
||||
{ name: 'Credits Used', data: usageSeries },
|
||||
{ name: 'Credits Added', data: purchasesSeries },
|
||||
];
|
||||
|
||||
// Prepare data for bar chart (top operations by count)
|
||||
const operationsByCount = [...analytics.usage_by_type]
|
||||
.filter(item => item.count > 0)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 8);
|
||||
|
||||
const barCategories = operationsByCount.map(item =>
|
||||
OPERATION_LABELS[item.transaction_type] || item.transaction_type.replace(/_/g, ' ')
|
||||
);
|
||||
const barSeries = operationsByCount.map(item => item.count);
|
||||
|
||||
const barOptions: ApexOptions = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
height: 300,
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
colors: ['var(--color-purple-500)'],
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: true,
|
||||
borderRadius: 4,
|
||||
barHeight: '60%',
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
xaxis: {
|
||||
categories: barCategories,
|
||||
labels: {
|
||||
style: {
|
||||
colors: 'var(--color-gray-500)',
|
||||
fontFamily: 'Outfit',
|
||||
},
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
colors: 'var(--color-gray-600)',
|
||||
fontFamily: 'Outfit',
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: 'var(--color-gray-200)',
|
||||
strokeDashArray: 4,
|
||||
},
|
||||
tooltip: {
|
||||
y: {
|
||||
formatter: (val: number) => `${val.toLocaleString()} operations`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Summary stats
|
||||
const avgDailyUsage = dailyData.length > 0
|
||||
? Math.round(dailyData.reduce((sum, d) => sum + Math.abs(d.usage), 0) / dailyData.length)
|
||||
: 0;
|
||||
|
||||
const peakUsage = dailyData.length > 0
|
||||
? Math.max(...dailyData.map(d => Math.abs(d.usage)))
|
||||
: 0;
|
||||
|
||||
const topOperation = usageByType.length > 0
|
||||
? usageByType.reduce((max, item) => Math.abs(item.total) > Math.abs(max.total) ? item : max, usageByType[0])
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||
<TrendingUpIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">Avg Daily Usage</div>
|
||||
<div className="text-xl font-bold text-brand-600 dark:text-brand-400">
|
||||
{avgDailyUsage.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">credits/day</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<BarChart3Icon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">Peak Usage</div>
|
||||
<div className="text-xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{peakUsage.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">credits in one day</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||
<ActivityIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">Top Operation</div>
|
||||
<div className="text-base font-bold text-success-600 dark:text-success-400 truncate max-w-[150px]">
|
||||
{topOperation
|
||||
? (OPERATION_LABELS[topOperation.transaction_type] || topOperation.transaction_type.replace(/_/g, ' '))
|
||||
: 'N/A'
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{topOperation ? `${Math.abs(topOperation.total).toLocaleString()} credits` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Usage by Type - Donut Chart */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||
<PieChartIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Credits by Type
|
||||
</h3>
|
||||
</div>
|
||||
{donutSeries.length > 0 ? (
|
||||
<Chart
|
||||
options={donutOptions}
|
||||
series={donutSeries}
|
||||
type="donut"
|
||||
height={320}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||
<div className="text-center">
|
||||
<PieChartIcon className="w-12 h-12 mx-auto mb-2 opacity-30" />
|
||||
<p>No usage data for this period</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Operations by Count - Bar Chart */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<BarChart3Icon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Operations Count
|
||||
</h3>
|
||||
</div>
|
||||
{barSeries.length > 0 ? (
|
||||
<Chart
|
||||
options={barOptions}
|
||||
series={[{ name: 'Operations', data: barSeries }]}
|
||||
type="bar"
|
||||
height={320}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||
<div className="text-center">
|
||||
<BarChart3Icon className="w-12 h-12 mx-auto mb-2 opacity-30" />
|
||||
<p>No operations in this period</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Daily Timeline - Full Width */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||
<TrendingUpIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Credit Activity Timeline
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Last {period} days
|
||||
</span>
|
||||
</div>
|
||||
{dailyData.length > 0 ? (
|
||||
<Chart
|
||||
options={timelineOptions}
|
||||
series={timelineSeries}
|
||||
type="area"
|
||||
height={300}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||
<div className="text-center">
|
||||
<TrendingUpIcon className="w-12 h-12 mx-auto mb-2 opacity-30" />
|
||||
<p>No daily activity data available</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
frontend/src/components/billing/InsufficientCreditsModal.tsx
Normal file
168
frontend/src/components/billing/InsufficientCreditsModal.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Insufficient Credits Modal
|
||||
* Shows when user doesn't have enough credits for an operation
|
||||
* Provides options to upgrade plan or buy credits
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '../ui/modal';
|
||||
import Button from '../ui/button/Button';
|
||||
import { ZapIcon, TrendingUpIcon, CreditCardIcon } from '../../icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface InsufficientCreditsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
requiredCredits: number;
|
||||
availableCredits: number;
|
||||
operationType?: string;
|
||||
}
|
||||
|
||||
export default function InsufficientCreditsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
requiredCredits,
|
||||
availableCredits,
|
||||
operationType = 'this operation',
|
||||
}: InsufficientCreditsModalProps) {
|
||||
const navigate = useNavigate();
|
||||
const shortfall = requiredCredits - availableCredits;
|
||||
|
||||
const handleUpgradePlan = () => {
|
||||
onClose();
|
||||
navigate('/account/billing/upgrade');
|
||||
};
|
||||
|
||||
const handleBuyCredits = () => {
|
||||
onClose();
|
||||
navigate('/account/billing/credits');
|
||||
};
|
||||
|
||||
const handleViewUsage = () => {
|
||||
onClose();
|
||||
navigate('/account/usage');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} showCloseButton={true}>
|
||||
<div className="p-6 text-center">
|
||||
{/* Warning Icon */}
|
||||
<div className="relative flex items-center justify-center w-20 h-20 mx-auto mb-6">
|
||||
<div className="absolute inset-0 bg-warning-100 dark:bg-warning-900/30 rounded-full"></div>
|
||||
<div className="relative bg-warning-500 rounded-full w-14 h-14 flex items-center justify-center">
|
||||
<ZapIcon className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Insufficient Credits
|
||||
</h2>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
You don't have enough credits for {operationType}.
|
||||
</p>
|
||||
|
||||
{/* Credit Stats */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-6">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Required</div>
|
||||
<div className="text-lg font-bold text-warning-600 dark:text-warning-400">
|
||||
{requiredCredits.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Available</div>
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{availableCredits.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Shortfall</div>
|
||||
<div className="text-lg font-bold text-error-600 dark:text-error-400">
|
||||
{shortfall.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={handleUpgradePlan}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<TrendingUpIcon className="w-4 h-4" />
|
||||
Upgrade Plan
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
fullWidth
|
||||
onClick={handleBuyCredits}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<CreditCardIcon className="w-4 h-4" />
|
||||
Buy Credits
|
||||
</Button>
|
||||
|
||||
<button
|
||||
onClick={handleViewUsage}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
View Usage Details →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cancel Button */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage insufficient credits modal state
|
||||
*/
|
||||
export function useInsufficientCreditsModal() {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [modalProps, setModalProps] = React.useState({
|
||||
requiredCredits: 0,
|
||||
availableCredits: 0,
|
||||
operationType: 'this operation',
|
||||
});
|
||||
|
||||
const showInsufficientCreditsModal = (props: {
|
||||
requiredCredits: number;
|
||||
availableCredits: number;
|
||||
operationType?: string;
|
||||
}) => {
|
||||
setModalProps({
|
||||
requiredCredits: props.requiredCredits,
|
||||
availableCredits: props.availableCredits,
|
||||
operationType: props.operationType || 'this operation',
|
||||
});
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => setIsOpen(false);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
modalProps,
|
||||
showInsufficientCreditsModal,
|
||||
closeModal,
|
||||
};
|
||||
}
|
||||
@@ -177,15 +177,11 @@ export default function UsageLimitsPanel() {
|
||||
sites: { icon: <GlobeIcon className="w-5 h-5" />, color: 'success' as const },
|
||||
users: { icon: <UsersIcon className="w-5 h-5" />, color: 'info' as const },
|
||||
keywords: { icon: <TagIcon className="w-5 h-5" />, color: 'purple' as const },
|
||||
clusters: { icon: <TrendingUpIcon className="w-5 h-5" />, color: 'warning' as const },
|
||||
};
|
||||
|
||||
// Simplified to only 1 monthly limit: Ahrefs keyword research queries
|
||||
const monthlyLimitConfig = {
|
||||
content_ideas: { icon: <FileTextIcon className="w-5 h-5" />, color: 'brand' as const },
|
||||
content_words: { icon: <FileTextIcon className="w-5 h-5" />, color: 'indigo' as const },
|
||||
images_basic: { icon: <ImageIcon className="w-5 h-5" />, color: 'teal' as const },
|
||||
images_premium: { icon: <ZapIcon className="w-5 h-5" />, color: 'cyan' as const },
|
||||
image_prompts: { icon: <ImageIcon className="w-5 h-5" />, color: 'pink' as const },
|
||||
ahrefs_queries: { icon: <TrendingUpIcon className="w-5 h-5" />, color: 'brand' as const },
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,11 +24,7 @@ export interface PricingPlan {
|
||||
max_sites?: number;
|
||||
max_users?: number;
|
||||
max_keywords?: number;
|
||||
max_clusters?: number;
|
||||
max_content_ideas?: number;
|
||||
max_content_words?: number;
|
||||
max_images_basic?: number;
|
||||
max_images_premium?: number;
|
||||
max_ahrefs_queries?: number;
|
||||
included_credits?: number;
|
||||
}
|
||||
|
||||
@@ -142,7 +138,7 @@ export function PricingTable({ variant = '1', title, plans, showToggle = false,
|
||||
))}
|
||||
|
||||
{/* Plan Limits Section */}
|
||||
{(plan.max_sites || plan.max_content_words || plan.included_credits) && (
|
||||
{(plan.max_sites || plan.max_keywords || plan.included_credits) && (
|
||||
<div className="pt-3 mt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2">LIMITS</div>
|
||||
{plan.max_sites && (
|
||||
@@ -161,27 +157,11 @@ export function PricingTable({ variant = '1', title, plans, showToggle = false,
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{plan.max_content_words && (
|
||||
{plan.max_keywords && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon className="w-4 h-4 text-brand-500 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{(plan.max_content_words / 1000).toLocaleString()}K Words/month
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{plan.max_content_ideas && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon className="w-4 h-4 text-brand-500 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{plan.max_content_ideas} Ideas/month
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{plan.max_images_basic && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon className="w-4 h-4 text-brand-500 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{plan.max_images_basic} Images/month
|
||||
{plan.max_keywords.toLocaleString()} Keywords
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
@@ -189,7 +169,7 @@ export function PricingTable({ variant = '1', title, plans, showToggle = false,
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon className="w-4 h-4 text-brand-500 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{plan.included_credits.toLocaleString()} Content pieces/month
|
||||
{plan.included_credits.toLocaleString()} Credits/month
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
|
||||
@@ -212,19 +212,14 @@ const AppSidebar: React.FC = () => {
|
||||
{
|
||||
icon: <DollarLineIcon />,
|
||||
name: "Plans & Billing",
|
||||
subItems: [
|
||||
{ name: "Current Plan", path: "/account/plans" },
|
||||
{ name: "Upgrade Plan", path: "/account/plans/upgrade" },
|
||||
{ name: "History", path: "/account/plans/history" },
|
||||
],
|
||||
path: "/account/plans",
|
||||
},
|
||||
{
|
||||
icon: <PieChartIcon />,
|
||||
name: "Usage",
|
||||
subItems: [
|
||||
{ name: "Limits & Usage", path: "/account/usage" },
|
||||
{ name: "Credit History", path: "/account/usage/credits" },
|
||||
{ name: "Activity", path: "/account/usage/activity" },
|
||||
{ name: "Dashboard", path: "/account/usage" },
|
||||
{ name: "Usage Logs", path: "/account/usage/logs" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,16 +13,8 @@ interface Plan {
|
||||
max_users: number;
|
||||
max_sites: number;
|
||||
max_keywords: number;
|
||||
max_clusters: number;
|
||||
max_content_ideas: number;
|
||||
monthly_word_count_limit: number;
|
||||
monthly_ai_credit_limit: number;
|
||||
monthly_image_count: number;
|
||||
daily_content_tasks: number;
|
||||
daily_ai_request_limit: number;
|
||||
daily_image_generation_limit: number;
|
||||
max_ahrefs_queries: number;
|
||||
included_credits: number;
|
||||
image_model_choices: string[];
|
||||
features: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -94,26 +94,25 @@ export default function SeedKeywords() {
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{keyword.industry_name}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{keyword.sector_name}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
|
||||
{keyword.volume.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
|
||||
{keyword.difficulty}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="light" color="info">{keyword.country_display}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{keyword.sector_name}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
|
||||
{keyword.volume.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
|
||||
{keyword.difficulty}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="light" color="info">{keyword.country_display}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,16 +16,8 @@ interface Plan {
|
||||
max_users: number;
|
||||
max_sites: number;
|
||||
max_keywords: number;
|
||||
max_clusters: number;
|
||||
max_content_ideas: number;
|
||||
monthly_word_count_limit: number;
|
||||
monthly_ai_credit_limit: number;
|
||||
monthly_image_count: number;
|
||||
daily_content_tasks: number;
|
||||
daily_ai_request_limit: number;
|
||||
daily_image_generation_limit: number;
|
||||
max_ahrefs_queries: number;
|
||||
included_credits: number;
|
||||
image_model_choices: string[];
|
||||
features: string[];
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { TrendingUpIcon, ActivityIcon, BarChart3Icon, ZapIcon, CalendarIcon } from '../../icons';
|
||||
import { TrendingUpIcon, ActivityIcon, BarChart3Icon, ZapIcon, CalendarIcon, PieChartIcon } from '../../icons';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
@@ -15,13 +15,15 @@ import { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import BillingUsagePanel from '../../components/billing/BillingUsagePanel';
|
||||
import UsageLimitsPanel from '../../components/billing/UsageLimitsPanel';
|
||||
import CreditInsightsCharts from '../../components/billing/CreditInsightsCharts';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
|
||||
type TabType = 'limits' | 'activity' | 'api';
|
||||
type TabType = 'limits' | 'activity' | 'insights' | 'api';
|
||||
|
||||
// Map URL paths to tab types
|
||||
function getTabFromPath(pathname: string): TabType {
|
||||
if (pathname.includes('/credits')) return 'activity';
|
||||
if (pathname.includes('/insights')) return 'insights';
|
||||
if (pathname.includes('/activity')) return 'api';
|
||||
return 'limits';
|
||||
}
|
||||
@@ -59,12 +61,14 @@ export default function UsageAnalyticsPage() {
|
||||
const tabTitles: Record<TabType, string> = {
|
||||
limits: 'Limits & Usage',
|
||||
activity: 'Credit History',
|
||||
insights: 'Credit Insights',
|
||||
api: 'Activity Log',
|
||||
};
|
||||
|
||||
const tabDescriptions: Record<TabType, string> = {
|
||||
limits: 'See how much you\'re using - Track your credits and content limits',
|
||||
activity: 'See where your credits go - Track credit usage history',
|
||||
insights: 'Visualize your usage patterns - Charts and analytics',
|
||||
api: 'Technical requests - Monitor API activity and usage',
|
||||
};
|
||||
|
||||
@@ -143,8 +147,8 @@ export default function UsageAnalyticsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Period Selector (only show on activity and api tabs) */}
|
||||
{(activeTab === 'activity' || activeTab === 'api') && (
|
||||
{/* Period Selector (only show on activity, insights and api tabs) */}
|
||||
{(activeTab === 'activity' || activeTab === 'api' || activeTab === 'insights') && (
|
||||
<div className="mb-6 flex justify-end">
|
||||
<div className="flex gap-2">
|
||||
{[7, 30, 90].map((value) => {
|
||||
@@ -181,6 +185,15 @@ export default function UsageAnalyticsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credit Insights Tab */}
|
||||
{activeTab === 'insights' && (
|
||||
<CreditInsightsCharts
|
||||
analytics={analytics}
|
||||
loading={loading}
|
||||
period={period}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* API Usage Tab */}
|
||||
{activeTab === 'api' && (
|
||||
<div className="space-y-6">
|
||||
|
||||
679
frontend/src/pages/account/UsageDashboardPage.tsx
Normal file
679
frontend/src/pages/account/UsageDashboardPage.tsx
Normal file
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* Usage Dashboard - Unified Analytics Page
|
||||
* Single comprehensive view of all usage, limits, and credit analytics
|
||||
* Replaces the 4-tab structure with a clean, organized dashboard
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Chart from 'react-apexcharts';
|
||||
import { ApexOptions } from 'apexcharts';
|
||||
import {
|
||||
TrendingUpIcon,
|
||||
ZapIcon,
|
||||
GlobeIcon,
|
||||
UsersIcon,
|
||||
TagIcon,
|
||||
SearchIcon,
|
||||
CalendarIcon,
|
||||
PieChartIcon,
|
||||
FileTextIcon,
|
||||
ImageIcon,
|
||||
RefreshCwIcon,
|
||||
ChevronDownIcon,
|
||||
ArrowRightIcon,
|
||||
} from '../../icons';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import {
|
||||
getUsageAnalytics,
|
||||
UsageAnalytics,
|
||||
getCreditBalance,
|
||||
type CreditBalance,
|
||||
getUsageSummary,
|
||||
type UsageSummary,
|
||||
type LimitUsage,
|
||||
getCreditUsageSummary,
|
||||
} from '../../services/billing.api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// User-friendly operation names - no model/token details
|
||||
const OPERATION_LABELS: Record<string, string> = {
|
||||
content_generation: 'Content Writing',
|
||||
image_generation: 'Image Creation',
|
||||
image_prompt_extraction: 'Image Prompts',
|
||||
keyword_clustering: 'Keyword Clustering',
|
||||
clustering: 'Keyword Clustering',
|
||||
idea_generation: 'Content Ideas',
|
||||
content_analysis: 'Content Analysis',
|
||||
linking: 'Internal Linking',
|
||||
};
|
||||
|
||||
// Chart colors - use hex for consistent coloring between pie chart and table
|
||||
const CHART_COLORS = [
|
||||
'#3b82f6', // blue - Content Writing
|
||||
'#ec4899', // pink - Image Prompts
|
||||
'#22c55e', // green - Content Ideas
|
||||
'#f59e0b', // amber - Keyword Clustering
|
||||
'#8b5cf6', // purple - Image Creation
|
||||
'#ef4444', // red
|
||||
'#14b8a6', // teal
|
||||
'#6366f1', // indigo
|
||||
];
|
||||
|
||||
// Map operation types to their output unit names
|
||||
const OPERATION_UNITS: Record<string, string> = {
|
||||
content_generation: 'Articles',
|
||||
image_generation: 'Images',
|
||||
image_prompt_extraction: 'Prompts',
|
||||
keyword_clustering: 'Clusters',
|
||||
clustering: 'Clusters',
|
||||
idea_generation: 'Ideas',
|
||||
content_analysis: 'Analyses',
|
||||
linking: 'Links',
|
||||
};
|
||||
|
||||
export default function UsageDashboardPage() {
|
||||
const toast = useToast();
|
||||
const [analytics, setAnalytics] = useState<UsageAnalytics | null>(null);
|
||||
const [creditBalance, setCreditBalance] = useState<CreditBalance | null>(null);
|
||||
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
|
||||
const [creditConsumption, setCreditConsumption] = useState<Record<string, { credits: number; cost: number; count: number }>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [period, setPeriod] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
loadAllData();
|
||||
}, [period]);
|
||||
|
||||
const loadAllData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Calculate start date for period
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - period);
|
||||
|
||||
const [analyticsData, balanceData, summaryData, consumptionData] = await Promise.all([
|
||||
getUsageAnalytics(period),
|
||||
getCreditBalance(),
|
||||
getUsageSummary(),
|
||||
getCreditUsageSummary({
|
||||
start_date: startDate.toISOString(),
|
||||
end_date: endDate.toISOString(),
|
||||
}),
|
||||
]);
|
||||
setAnalytics(analyticsData);
|
||||
setCreditBalance(balanceData);
|
||||
setUsageSummary(summaryData);
|
||||
setCreditConsumption(consumptionData.by_operation || {});
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load usage data: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate credit usage percentage
|
||||
const creditPercentage = creditBalance && creditBalance.plan_credits_per_month > 0
|
||||
? Math.round((creditBalance.credits_used_this_month / creditBalance.plan_credits_per_month) * 100)
|
||||
: 0;
|
||||
|
||||
// Prepare timeline chart data
|
||||
const dailyData = analytics?.daily_usage || [];
|
||||
const timelineCategories = dailyData.map(d => {
|
||||
const date = new Date(d.date);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
});
|
||||
|
||||
const timelineOptions: ApexOptions = {
|
||||
chart: {
|
||||
type: 'area',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
height: 200,
|
||||
sparkline: { enabled: false },
|
||||
toolbar: { show: false },
|
||||
zoom: { enabled: false },
|
||||
},
|
||||
colors: ['var(--color-brand-500)'],
|
||||
dataLabels: { enabled: false },
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: { shadeIntensity: 1, opacityFrom: 0.4, opacityTo: 0.1, stops: [0, 90, 100] },
|
||||
},
|
||||
xaxis: {
|
||||
categories: timelineCategories,
|
||||
axisBorder: { show: false },
|
||||
axisTicks: { show: false },
|
||||
labels: {
|
||||
style: { colors: 'var(--color-gray-500)', fontFamily: 'Outfit' },
|
||||
rotate: -45,
|
||||
rotateAlways: dailyData.length > 14,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: { colors: 'var(--color-gray-500)', fontFamily: 'Outfit' },
|
||||
formatter: (val: number) => val.toLocaleString(),
|
||||
},
|
||||
},
|
||||
grid: { borderColor: 'var(--color-gray-200)', strokeDashArray: 4 },
|
||||
tooltip: { y: { formatter: (val: number) => `${val.toLocaleString()} credits` } },
|
||||
};
|
||||
|
||||
// Prepare donut chart for credit consumption (from creditConsumption state)
|
||||
// Sort by credits descending so pie chart and table colors match
|
||||
const consumptionEntries = Object.entries(creditConsumption)
|
||||
.filter(([_, data]) => data.credits > 0)
|
||||
.sort((a, b) => b[1].credits - a[1].credits);
|
||||
const donutLabels = consumptionEntries.map(([opType]) => OPERATION_LABELS[opType] || opType.replace(/_/g, ' '));
|
||||
const donutSeries = consumptionEntries.map(([_, data]) => data.credits);
|
||||
const totalCreditsUsed = donutSeries.reduce((sum, val) => sum + val, 0);
|
||||
|
||||
const donutOptions: ApexOptions = {
|
||||
chart: { type: 'donut', fontFamily: 'Outfit, sans-serif' },
|
||||
labels: donutLabels,
|
||||
colors: CHART_COLORS.slice(0, donutLabels.length),
|
||||
legend: { show: false },
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
size: '75%',
|
||||
labels: {
|
||||
show: true,
|
||||
name: { show: true, fontSize: '11px', fontFamily: 'Outfit', color: '#6b7280' },
|
||||
value: { show: true, fontSize: '18px', fontFamily: 'Outfit', fontWeight: 600, color: '#111827', formatter: (val: string) => parseInt(val).toLocaleString() },
|
||||
total: { show: true, label: 'Total Credits', fontSize: '11px', fontFamily: 'Outfit', color: '#6b7280', formatter: () => totalCreditsUsed.toLocaleString() },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dataLabels: { enabled: false },
|
||||
tooltip: {
|
||||
custom: function({ series, seriesIndex, w }) {
|
||||
const label = w.globals.labels[seriesIndex];
|
||||
const value = series[seriesIndex];
|
||||
return `<div style="background: #1f2937; color: #fff; padding: 8px 12px; border-radius: 6px; font-size: 12px; font-family: Outfit, sans-serif;">
|
||||
<span style="font-weight: 500;">${label}:</span> ${value.toLocaleString()} credits
|
||||
</div>`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Limit card component with Coming Soon support
|
||||
const LimitCard = ({
|
||||
title,
|
||||
icon,
|
||||
usage,
|
||||
type,
|
||||
color,
|
||||
comingSoon = false,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
usage: LimitUsage | undefined;
|
||||
type: 'hard' | 'monthly';
|
||||
color: string;
|
||||
comingSoon?: boolean;
|
||||
}) => {
|
||||
if (comingSoon) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 bg-warning-50 dark:bg-warning-900/20 rounded-xl border border-warning-200 dark:border-warning-800">
|
||||
<div className="p-2.5 rounded-lg bg-warning-100 dark:bg-warning-900/30 text-warning-600 dark:text-warning-400">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="font-medium text-gray-900 dark:text-white text-sm">{title}</span>
|
||||
<Badge variant="soft" tone="warning" size="sm">Coming Soon</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">This feature is not yet available</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!usage) return null;
|
||||
|
||||
const percentage = usage.percentage_used;
|
||||
const isWarning = percentage >= 80;
|
||||
const isDanger = percentage >= 95;
|
||||
const barColor = isDanger ? 'var(--color-error-500)' : isWarning ? 'var(--color-warning-500)' : color;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl">
|
||||
<div
|
||||
className="p-2.5 rounded-lg shrink-0"
|
||||
style={{ backgroundColor: `${barColor}15`, color: barColor }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium text-gray-900 dark:text-white text-sm">{title}</span>
|
||||
<Badge
|
||||
variant="soft"
|
||||
tone={isDanger ? 'danger' : isWarning ? 'warning' : 'brand'}
|
||||
size="sm"
|
||||
>
|
||||
{percentage}%
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden mb-1">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(percentage, 100)}%`, backgroundColor: barColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{usage.current.toLocaleString()} / {usage.limit.toLocaleString()}</span>
|
||||
<span>{usage.remaining.toLocaleString()} left</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Usage Dashboard" description="Your complete usage overview" />
|
||||
<PageHeader
|
||||
title="Usage Dashboard"
|
||||
description="Your complete usage overview"
|
||||
badge={{ icon: <TrendingUpIcon className="w-4 h-4" />, color: 'blue' }}
|
||||
/>
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="h-32 bg-gray-200 dark:bg-gray-800 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-64 bg-gray-200 dark:bg-gray-800 rounded-xl" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Usage Dashboard" description="Your complete usage overview" />
|
||||
<PageHeader
|
||||
title="Usage Dashboard"
|
||||
description="Your complete usage overview at a glance"
|
||||
badge={{ icon: <TrendingUpIcon className="w-4 h-4" />, color: 'blue' }}
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||
{[7, 30, 90].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setPeriod(value)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
period === value
|
||||
? 'bg-white dark:bg-gray-700 text-brand-600 dark:text-brand-400 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{value}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
tone="neutral"
|
||||
onClick={loadAllData}
|
||||
startIcon={<RefreshCwIcon className="w-4 h-4" />}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* SECTION 1: Credit Overview - Hero Stats */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Credit Card */}
|
||||
<Card className="lg:col-span-2 p-6 bg-gradient-to-br from-brand-50 to-purple-50 dark:from-brand-900/20 dark:to-purple-900/20 border-0">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">Credit Balance</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Your available credits for AI operations</p>
|
||||
</div>
|
||||
<Link to="/account/plans">
|
||||
<Button size="sm" variant="primary" tone="brand">
|
||||
Buy Credits
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-brand-600 dark:text-brand-400 mb-1">
|
||||
{creditBalance?.credits.toLocaleString() || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Available Now</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-purple-600 dark:text-purple-400 mb-1">
|
||||
{creditBalance?.credits_used_this_month.toLocaleString() || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Used This Month</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{creditBalance?.plan_credits_per_month.toLocaleString() || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Monthly Allowance</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credit Usage Bar */}
|
||||
<div className="mt-6">
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-gray-600 dark:text-gray-400">Monthly Usage</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{creditPercentage}%</span>
|
||||
</div>
|
||||
<div className="h-3 bg-white/50 dark:bg-gray-800/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500 bg-gradient-to-r from-brand-500 to-purple-500"
|
||||
style={{ width: `${Math.min(creditPercentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Plan Info Card */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||
<ZapIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{usageSummary?.plan_name || 'Your Plan'}</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Current subscription</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Billing Period</span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{usageSummary?.period_start ? new Date(usageSummary.period_start).toLocaleDateString() : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Resets In</span>
|
||||
<span className="font-medium text-brand-600 dark:text-brand-400">
|
||||
{usageSummary?.days_until_reset || 0} days
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link to="/account/plans/upgrade">
|
||||
<Button size="sm" variant="outline" tone="brand" className="w-full">
|
||||
Upgrade Plan
|
||||
</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* SECTION 2: Your Limits */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Your Limits</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Track your plan resources</p>
|
||||
</div>
|
||||
{usageSummary?.days_until_reset !== undefined && (
|
||||
<Badge variant="soft" tone="info">
|
||||
<CalendarIcon className="w-3 h-3 mr-1" />
|
||||
Resets in {usageSummary.days_until_reset} days
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<LimitCard
|
||||
title="Sites"
|
||||
icon={<GlobeIcon className="w-4 h-4" />}
|
||||
usage={usageSummary?.hard_limits?.sites}
|
||||
type="hard"
|
||||
color="var(--color-brand-500)"
|
||||
/>
|
||||
<LimitCard
|
||||
title="Team Members"
|
||||
icon={<UsersIcon className="w-4 h-4" />}
|
||||
usage={usageSummary?.hard_limits?.users}
|
||||
type="hard"
|
||||
color="var(--color-purple-500)"
|
||||
/>
|
||||
<LimitCard
|
||||
title="Keywords"
|
||||
icon={<TagIcon className="w-4 h-4" />}
|
||||
usage={usageSummary?.hard_limits?.keywords}
|
||||
type="hard"
|
||||
color="var(--color-success-500)"
|
||||
/>
|
||||
<LimitCard
|
||||
title="Keyword Research"
|
||||
icon={<SearchIcon className="w-4 h-4" />}
|
||||
usage={undefined}
|
||||
type="monthly"
|
||||
color="var(--color-warning-500)"
|
||||
comingSoon={true}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* SECTION 3: Activity Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Timeline Chart */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||
<TrendingUpIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Credit Usage Over Time</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Last {period} days</p>
|
||||
</div>
|
||||
</div>
|
||||
{dailyData.length > 0 ? (
|
||||
<Chart
|
||||
options={timelineOptions}
|
||||
series={[{ name: 'Credits Used', data: dailyData.map(d => Math.abs(d.usage)) }]}
|
||||
type="area"
|
||||
height={200}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-48 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
<div className="text-center">
|
||||
<TrendingUpIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">No activity in this period</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Credit Consumption - Pie + Table */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<PieChartIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Credit Consumption</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Last {period} days by operation</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Donut Chart */}
|
||||
<div>
|
||||
{donutSeries.length > 0 ? (
|
||||
<Chart
|
||||
options={donutOptions}
|
||||
series={donutSeries}
|
||||
type="donut"
|
||||
height={240}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-48 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
<div className="text-center">
|
||||
<PieChartIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">No usage data yet</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Consumption Table */}
|
||||
<div className="overflow-auto max-h-64">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-white dark:bg-gray-900">
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-1.5 font-medium text-gray-600 dark:text-gray-400">Operation</th>
|
||||
<th className="text-right py-1.5 font-medium text-gray-600 dark:text-gray-400">Credits</th>
|
||||
<th className="text-right py-1.5 font-medium text-gray-600 dark:text-gray-400">Output</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{consumptionEntries.length > 0 ? (
|
||||
consumptionEntries.map(([opType, data], index) => (
|
||||
<tr key={opType} className="border-b border-gray-100 dark:border-gray-800 last:border-0">
|
||||
<td className="py-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||
/>
|
||||
<span className="text-gray-900 dark:text-white truncate">
|
||||
{OPERATION_LABELS[opType] || opType.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-medium text-gray-900 dark:text-white">
|
||||
{data.credits.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{data.count} {OPERATION_UNITS[opType] || 'Items'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-4 text-center text-gray-500 dark:text-gray-400">
|
||||
No consumption data
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* SECTION 4: Quick Link to Detailed Logs */}
|
||||
<Card className="p-6 bg-gradient-to-r from-gray-50 to-brand-50 dark:from-gray-800 dark:to-brand-900/20 border-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||
<FileTextIcon className="w-6 h-6 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Need More Details?</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
View complete history of all AI operations with filters, dates, and USD costs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/account/usage/logs">
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
endIcon={<ArrowRightIcon className="w-4 h-4" />}
|
||||
>
|
||||
View Usage Logs
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* SECTION 5: Credit Costs Reference (Collapsible) */}
|
||||
<Card className="p-6">
|
||||
<details className="group">
|
||||
<summary className="flex items-center justify-between cursor-pointer list-none">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<ZapIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">How Credits Work</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">See estimated costs for each operation</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500 group-open:rotate-180 transition-transform" />
|
||||
</summary>
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileTextIcon className="w-4 h-4 text-brand-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">Content Writing</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">~1 credit per 100 words</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ImageIcon className="w-4 h-4 text-purple-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">Image Creation</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">1-15 credits per image (by quality)</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TagIcon className="w-4 h-4 text-success-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">Keyword Grouping</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">~10 credits per batch</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ZapIcon className="w-4 h-4 text-warning-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">Content Ideas</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">~15 credits per cluster</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileTextIcon className="w-4 h-4 text-cyan-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">Image Prompts</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">~2 credits per prompt</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg relative">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<SearchIcon className="w-4 h-4 text-gray-400" />
|
||||
<span className="font-medium text-gray-500 dark:text-gray-400">Keyword Research</span>
|
||||
<Badge variant="soft" tone="warning" size="sm">Soon</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">Uses monthly limit (not credits)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
419
frontend/src/pages/account/UsageLogsPage.tsx
Normal file
419
frontend/src/pages/account/UsageLogsPage.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Usage Logs Page - Detailed AI Operation Logs
|
||||
* Shows a filterable, paginated table of all credit usage
|
||||
* Consistent layout with Planner/Writer table pages
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CalendarIcon,
|
||||
FileTextIcon,
|
||||
ImageIcon,
|
||||
TagIcon,
|
||||
ZapIcon,
|
||||
RefreshCwIcon,
|
||||
DollarSignIcon,
|
||||
TrendingUpIcon,
|
||||
} from '../../icons';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import SelectDropdown from '../../components/form/SelectDropdown';
|
||||
import Input from '../../components/form/input/InputField';
|
||||
import { Pagination } from '../../components/ui/pagination/Pagination';
|
||||
import { getCreditUsage, type CreditUsageLog } from '../../services/billing.api';
|
||||
|
||||
// User-friendly operation names (no model/token details)
|
||||
const OPERATION_LABELS: Record<string, string> = {
|
||||
content_generation: 'Content Writing',
|
||||
image_generation: 'Image Creation',
|
||||
image_prompt_extraction: 'Image Prompts',
|
||||
keyword_clustering: 'Keyword Clustering',
|
||||
clustering: 'Keyword Clustering',
|
||||
idea_generation: 'Content Ideas',
|
||||
content_analysis: 'Content Analysis',
|
||||
linking: 'Internal Linking',
|
||||
};
|
||||
|
||||
// Operation icons
|
||||
const OPERATION_ICONS: Record<string, React.ReactNode> = {
|
||||
content_generation: <FileTextIcon className="w-3.5 h-3.5" />,
|
||||
image_generation: <ImageIcon className="w-3.5 h-3.5" />,
|
||||
image_prompt_extraction: <FileTextIcon className="w-3.5 h-3.5" />,
|
||||
keyword_clustering: <TagIcon className="w-3.5 h-3.5" />,
|
||||
clustering: <TagIcon className="w-3.5 h-3.5" />,
|
||||
idea_generation: <ZapIcon className="w-3.5 h-3.5" />,
|
||||
content_analysis: <FileTextIcon className="w-3.5 h-3.5" />,
|
||||
linking: <TagIcon className="w-3.5 h-3.5" />,
|
||||
};
|
||||
|
||||
// Operation type options for filter (only enabled operations)
|
||||
const OPERATION_OPTIONS = [
|
||||
{ value: '', label: 'All Operations' },
|
||||
{ value: 'content_generation', label: 'Content Writing' },
|
||||
{ value: 'image_generation', label: 'Image Creation' },
|
||||
{ value: 'image_prompt_extraction', label: 'Image Prompts' },
|
||||
{ value: 'keyword_clustering', label: 'Keyword Clustering' },
|
||||
{ value: 'idea_generation', label: 'Content Ideas' },
|
||||
];
|
||||
|
||||
export default function UsageLogsPage() {
|
||||
const toast = useToast();
|
||||
|
||||
// Data state
|
||||
const [logs, setLogs] = useState<CreditUsageLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
|
||||
// Filter state
|
||||
const [operationFilter, setOperationFilter] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
// Calculate total pages
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
// Load usage logs
|
||||
const loadLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params: any = {};
|
||||
|
||||
if (operationFilter) {
|
||||
params.operation_type = operationFilter;
|
||||
}
|
||||
if (startDate) {
|
||||
params.start_date = startDate;
|
||||
}
|
||||
if (endDate) {
|
||||
params.end_date = endDate;
|
||||
}
|
||||
|
||||
// Add pagination params
|
||||
params.page = currentPage;
|
||||
params.page_size = pageSize;
|
||||
|
||||
const data = await getCreditUsage(params);
|
||||
setLogs(data.results || []);
|
||||
setTotalCount(data.count || 0);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load usage logs: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load on mount and when filters change
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, [currentPage, operationFilter, startDate, endDate]);
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [operationFilter, startDate, endDate]);
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Format cost in USD
|
||||
const formatCost = (cost: string | null | undefined) => {
|
||||
if (!cost) return '$0.00';
|
||||
const num = parseFloat(cost);
|
||||
if (isNaN(num)) return '$0.00';
|
||||
return `$${num.toFixed(4)}`;
|
||||
};
|
||||
|
||||
// Get operation display info
|
||||
const getOperationDisplay = (type: string) => {
|
||||
return {
|
||||
label: OPERATION_LABELS[type] || type.replace(/_/g, ' '),
|
||||
icon: OPERATION_ICONS[type] || <ZapIcon className="w-3.5 h-3.5" />,
|
||||
};
|
||||
};
|
||||
|
||||
// Summary stats - calculate from all loaded logs
|
||||
const summaryStats = useMemo(() => {
|
||||
const totalCredits = logs.reduce((sum, log) => sum + log.credits_used, 0);
|
||||
const totalCost = logs.reduce((sum, log) => sum + (parseFloat(log.cost_usd || '0') || 0), 0);
|
||||
const avgCreditsPerOp = logs.length > 0 ? Math.round(totalCredits / logs.length) : 0;
|
||||
|
||||
// Count by operation type
|
||||
const byOperation = logs.reduce((acc, log) => {
|
||||
const op = log.operation_type;
|
||||
acc[op] = (acc[op] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
const topOperation = Object.entries(byOperation).sort((a, b) => b[1] - a[1])[0];
|
||||
|
||||
return { totalCredits, totalCost, avgCreditsPerOp, topOperation };
|
||||
}, [logs]);
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = () => {
|
||||
setOperationFilter('');
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
};
|
||||
|
||||
const hasActiveFilters = operationFilter || startDate || endDate;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Usage Logs" description="Detailed log of all AI operations" />
|
||||
<PageHeader
|
||||
title="Usage Logs"
|
||||
description="Detailed history of all your AI operations and credit usage"
|
||||
badge={{ icon: <FileTextIcon className="w-4 h-4" />, color: 'purple' }}
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/account/usage">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
tone="neutral"
|
||||
startIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
tone="neutral"
|
||||
onClick={loadLogs}
|
||||
startIcon={<RefreshCwIcon className="w-4 h-4" />}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Summary Cards - 5 metrics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||
<ZapIcon className="w-4 h-4 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{summaryStats.totalCredits.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Credits Used
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||
<DollarSignIcon className="w-4 h-4 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{formatCost(summaryStats.totalCost.toString())}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Total Cost
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<CalendarIcon className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{totalCount.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Operations
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-warning-100 dark:bg-warning-900/30 rounded-lg">
|
||||
<TrendingUpIcon className="w-4 h-4 text-warning-600 dark:text-warning-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{summaryStats.avgCreditsPerOp}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Avg/Operation
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-info-100 dark:bg-info-900/30 rounded-lg">
|
||||
<FileTextIcon className="w-4 h-4 text-info-600 dark:text-info-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white truncate">
|
||||
{summaryStats.topOperation ? OPERATION_LABELS[summaryStats.topOperation[0]] || summaryStats.topOperation[0] : '-'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Top Operation
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters - Inline style like Planner pages */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="w-44">
|
||||
<SelectDropdown
|
||||
options={OPERATION_OPTIONS}
|
||||
value={operationFilter}
|
||||
onChange={setOperationFilter}
|
||||
placeholder="All Operations"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36">
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
placeholder="Start Date"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36">
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
placeholder="End Date"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-sm text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table - Half width on large screens */}
|
||||
<div className="lg:w-1/2">
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="igny8-table-compact min-w-full w-full">
|
||||
<thead className="border-b border-gray-100 dark:border-white/[0.05]">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">Date</th>
|
||||
<th className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">Operation</th>
|
||||
<th className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">Credits</th>
|
||||
<th className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">Cost (USD)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
|
||||
{loading ? (
|
||||
// Loading skeleton
|
||||
Array.from({ length: 10 }).map((_, i) => (
|
||||
<tr key={i} className="igny8-skeleton-row">
|
||||
<td className="px-5 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-28" /></td>
|
||||
<td className="px-5 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-24" /></td>
|
||||
<td className="px-5 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-12 mx-auto" /></td>
|
||||
<td className="px-5 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-16 mx-auto" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<div className="text-center py-12">
|
||||
<FileTextIcon className="w-10 h-10 mx-auto text-gray-300 dark:text-gray-600 mb-3" />
|
||||
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-1">
|
||||
No usage logs found
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{hasActiveFilters
|
||||
? 'Try adjusting your filters to see more results.'
|
||||
: 'Your AI operation history will appear here.'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => {
|
||||
const operationDisplay = getOperationDisplay(log.operation_type);
|
||||
return (
|
||||
<tr key={log.id} className="igny8-data-row">
|
||||
<td className="px-5 py-2.5 text-gray-600 dark:text-gray-400">
|
||||
{formatDate(log.created_at)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-gray-100 dark:bg-gray-800 rounded">
|
||||
{operationDisplay.icon}
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{operationDisplay.label}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-center font-medium text-gray-900 dark:text-white">
|
||||
{log.credits_used.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-center text-gray-600 dark:text-gray-400">
|
||||
{formatCost(log.cost_usd)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-5 py-3 border-t border-gray-100 dark:border-white/[0.05] flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, totalCount)} of {totalCount.toLocaleString()}
|
||||
</span>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
variant="icon"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -225,6 +225,8 @@ export async function getCreditUsage(params?: {
|
||||
operation_type?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<{
|
||||
results: CreditUsageLog[];
|
||||
count: number;
|
||||
@@ -233,6 +235,8 @@ export async function getCreditUsage(params?: {
|
||||
if (params?.operation_type) queryParams.append('operation_type', params.operation_type);
|
||||
if (params?.start_date) queryParams.append('start_date', params.start_date);
|
||||
if (params?.end_date) queryParams.append('end_date', params.end_date);
|
||||
if (params?.page) queryParams.append('page', params.page.toString());
|
||||
if (params?.page_size) queryParams.append('page_size', params.page_size.toString());
|
||||
|
||||
const url = `/v1/billing/credits/usage/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
return fetchAPI(url);
|
||||
@@ -905,17 +909,13 @@ export interface Plan {
|
||||
features?: string[];
|
||||
limits?: Record<string, any>;
|
||||
display_order?: number;
|
||||
// Hard Limits
|
||||
// Hard Limits (only 3 persistent limits)
|
||||
max_sites?: number;
|
||||
max_users?: number;
|
||||
max_keywords?: number;
|
||||
max_clusters?: number;
|
||||
// Monthly Limits
|
||||
max_content_ideas?: number;
|
||||
max_content_words?: number;
|
||||
max_images_basic?: number;
|
||||
max_images_premium?: number;
|
||||
max_image_prompts?: number;
|
||||
// Monthly Limits (only ahrefs queries)
|
||||
max_ahrefs_queries?: number;
|
||||
// Credits
|
||||
included_credits?: number;
|
||||
}
|
||||
|
||||
@@ -934,18 +934,15 @@ export interface UsageSummary {
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
days_until_reset: number;
|
||||
// Simplified to only 3 hard limits
|
||||
hard_limits: {
|
||||
sites?: LimitUsage;
|
||||
users?: LimitUsage;
|
||||
keywords?: LimitUsage;
|
||||
clusters?: LimitUsage;
|
||||
};
|
||||
// Simplified to only 1 monthly limit (Ahrefs queries)
|
||||
monthly_limits: {
|
||||
content_ideas?: LimitUsage;
|
||||
content_words?: LimitUsage;
|
||||
images_basic?: LimitUsage;
|
||||
images_premium?: LimitUsage;
|
||||
image_prompts?: LimitUsage;
|
||||
ahrefs_queries?: LimitUsage;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
79
frontend/src/utils/creditCheck.ts
Normal file
79
frontend/src/utils/creditCheck.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Credit Check Utilities
|
||||
* Pre-flight credit checks for AI operations
|
||||
*/
|
||||
|
||||
import { getCreditBalance, type CreditBalance } from '../services/billing.api';
|
||||
|
||||
export interface CreditCheckResult {
|
||||
hasEnoughCredits: boolean;
|
||||
availableCredits: number;
|
||||
requiredCredits: number;
|
||||
shortfall: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account has enough credits for an operation
|
||||
* @param estimatedCredits - Estimated credits needed for the operation
|
||||
* @returns Credit check result with balance info
|
||||
*/
|
||||
export async function checkCreditsBeforeOperation(
|
||||
estimatedCredits: number
|
||||
): Promise<CreditCheckResult> {
|
||||
try {
|
||||
const balance: CreditBalance = await getCreditBalance();
|
||||
const hasEnoughCredits = balance.credits >= estimatedCredits;
|
||||
|
||||
return {
|
||||
hasEnoughCredits,
|
||||
availableCredits: balance.credits,
|
||||
requiredCredits: estimatedCredits,
|
||||
shortfall: Math.max(0, estimatedCredits - balance.credits),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to check credit balance:', error);
|
||||
// Return pessimistic result on error to prevent operation
|
||||
return {
|
||||
hasEnoughCredits: false,
|
||||
availableCredits: 0,
|
||||
requiredCredits: estimatedCredits,
|
||||
shortfall: estimatedCredits,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated credit costs for common operations
|
||||
* These are estimates - actual costs depend on token usage
|
||||
*/
|
||||
export const ESTIMATED_CREDIT_COSTS = {
|
||||
// Content Generation
|
||||
content_generation_short: 5, // ~500 words
|
||||
content_generation_medium: 10, // ~1000 words
|
||||
content_generation_long: 20, // ~2000+ words
|
||||
|
||||
// Clustering & Planning
|
||||
cluster_keywords: 3, // Per clustering operation
|
||||
generate_content_ideas: 5, // Per batch of ideas
|
||||
|
||||
// Image Generation
|
||||
image_basic: 2, // Basic quality
|
||||
image_premium: 5, // Premium quality
|
||||
|
||||
// SEO Optimization
|
||||
seo_analysis: 2,
|
||||
seo_optimization: 3,
|
||||
|
||||
// Internal Linking
|
||||
internal_linking: 2,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get estimated cost for an operation type
|
||||
*/
|
||||
export function getEstimatedCost(
|
||||
operationType: keyof typeof ESTIMATED_CREDIT_COSTS,
|
||||
quantity: number = 1
|
||||
): number {
|
||||
return ESTIMATED_CREDIT_COSTS[operationType] * quantity;
|
||||
}
|
||||
@@ -16,12 +16,7 @@ export interface Plan {
|
||||
max_sites?: number;
|
||||
max_users?: number;
|
||||
max_keywords?: number;
|
||||
max_clusters?: number;
|
||||
max_content_ideas?: number;
|
||||
max_content_words?: number;
|
||||
max_images_basic?: number;
|
||||
max_images_premium?: number;
|
||||
max_image_prompts?: number;
|
||||
max_ahrefs_queries?: number;
|
||||
included_credits?: number;
|
||||
}
|
||||
|
||||
@@ -37,8 +32,8 @@ export const convertToPricingPlan = (plan: Plan): PricingPlan => {
|
||||
const features: string[] = [];
|
||||
|
||||
// Dynamic counts - shown with numbers from backend
|
||||
if (plan.max_content_ideas) {
|
||||
features.push(`**${formatNumber(plan.max_content_ideas)} Pages/Articles per month**`);
|
||||
if (plan.max_keywords) {
|
||||
features.push(`**${formatNumber(plan.max_keywords)} Keywords**`);
|
||||
}
|
||||
if (plan.max_sites) {
|
||||
features.push(`${plan.max_sites === 999999 ? 'Unlimited' : formatNumber(plan.max_sites)} Site${plan.max_sites > 1 && plan.max_sites !== 999999 ? 's' : ''}`);
|
||||
|
||||
Reference in New Issue
Block a user