Phase 3 - credts, usage, plans app pages #Migrations

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-06 21:28:13 +00:00
parent cb8e747387
commit 9ca048fb9d
37 changed files with 9328 additions and 1149 deletions

View File

@@ -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[];
}

View File

@@ -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>
</>
);
}

View File

@@ -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

View File

@@ -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">

View 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>
</>
);
}

View 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>
</>
);
}