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

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

View File

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

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

View 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,
};
}

View File

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

View File

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