diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 24f5080..a02b46d 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -193,6 +193,114 @@ async def get_admin_stats( } +# ============== Earnings / Revenue ============== + +@router.get("/earnings") +async def get_admin_earnings( + db: Database, + admin: User = Depends(require_admin) +): + """ + Get earnings and revenue metrics for admin dashboard. + + Calculates MRR, ARR, and subscription breakdown. + """ + # Tier prices (from TIER_CONFIG) + tier_prices = { + SubscriptionTier.SCOUT: 0, + SubscriptionTier.TRADER: 9, + SubscriptionTier.TYCOON: 29, + } + + # Get all active subscriptions + result = await db.execute( + select(Subscription).where( + Subscription.status == SubscriptionStatus.ACTIVE + ) + ) + active_subs = result.scalars().all() + + # Calculate MRR + mrr = 0.0 + tier_breakdown = { + "scout": {"count": 0, "revenue": 0}, + "trader": {"count": 0, "revenue": 0}, + "tycoon": {"count": 0, "revenue": 0}, + } + + for sub in active_subs: + price = tier_prices.get(sub.tier, 0) + mrr += price + tier_key = sub.tier.value + if tier_key in tier_breakdown: + tier_breakdown[tier_key]["count"] += 1 + tier_breakdown[tier_key]["revenue"] += price + + arr = mrr * 12 + + # New subscriptions this week + week_ago = datetime.utcnow() - timedelta(days=7) + new_subs_week = await db.execute( + select(func.count(Subscription.id)).where( + Subscription.started_at >= week_ago, + Subscription.tier != SubscriptionTier.SCOUT + ) + ) + new_subs_week = new_subs_week.scalar() or 0 + + # New subscriptions this month + month_ago = datetime.utcnow() - timedelta(days=30) + new_subs_month = await db.execute( + select(func.count(Subscription.id)).where( + Subscription.started_at >= month_ago, + Subscription.tier != SubscriptionTier.SCOUT + ) + ) + new_subs_month = new_subs_month.scalar() or 0 + + # Cancelled subscriptions this month (churn) + cancelled_month = await db.execute( + select(func.count(Subscription.id)).where( + Subscription.cancelled_at >= month_ago, + Subscription.cancelled_at.isnot(None) + ) + ) + cancelled_month = cancelled_month.scalar() or 0 + + # Total paying customers + paying_customers = tier_breakdown["trader"]["count"] + tier_breakdown["tycoon"]["count"] + + # Revenue from Yield (platform's 30% cut) + try: + from app.models.yield_domain import YieldTransaction + yield_revenue = await db.execute( + select(func.sum(YieldTransaction.net_amount)).where( + YieldTransaction.created_at >= month_ago, + YieldTransaction.status == "confirmed" + ) + ) + yield_revenue_month = float(yield_revenue.scalar() or 0) * 0.30 / 0.70 # Platform's cut + except Exception: + yield_revenue_month = 0 + + return { + "mrr": round(mrr, 2), + "arr": round(arr, 2), + "paying_customers": paying_customers, + "tier_breakdown": tier_breakdown, + "new_subscriptions": { + "week": new_subs_week, + "month": new_subs_month, + }, + "churn": { + "month": cancelled_month, + }, + "yield_revenue_month": round(yield_revenue_month, 2), + "total_revenue_month": round(mrr + yield_revenue_month, 2), + "timestamp": datetime.utcnow().isoformat(), + } + + # ============== User Management ============== class UpdateUserRequest(BaseModel): diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index f880edf..fffaf1d 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,9 +1,11 @@ 'use client' import { useEffect, useState } from 'react' -import { AdminLayout } from '@/components/AdminLayout' -import { PremiumTable, Badge, TableActionButton, StatCard, PageContainer } from '@/components/PremiumTable' +import { useRouter } from 'next/navigation' +import { useStore } from '@/lib/store' import { api } from '@/lib/api' +import { EarningsTab } from '@/components/admin/EarningsTab' +import { PremiumTable, Badge, TableActionButton, StatCard } from '@/components/PremiumTable' import { Users, Mail, @@ -38,10 +40,23 @@ import { Target, Wallet, Link, + DollarSign, + ChevronLeft, + ChevronRight, + LogOut, + LayoutDashboard, + Menu, + Settings, } from 'lucide-react' import clsx from 'clsx' +import NextLink from 'next/link' +import Image from 'next/image' -type TabType = 'overview' | 'telemetry' | 'users' | 'alerts' | 'newsletter' | 'tld' | 'auctions' | 'blog' | 'system' | 'activity' +// ============================================================================ +// TYPES +// ============================================================================ + +type TabType = 'overview' | 'earnings' | 'telemetry' | 'users' | 'alerts' | 'newsletter' | 'tld' | 'auctions' | 'blog' | 'system' | 'activity' interface AdminStats { users: { total: number; active: number; verified: number; new_this_week: number } @@ -66,15 +81,41 @@ interface AdminUser { subscription: { tier: string; tier_name: string; status: string | null; domain_limit: number } } +// ============================================================================ +// TAB CONFIG +// ============================================================================ + +const TABS: Array<{ id: TabType; label: string; icon: any; shortLabel?: string }> = [ + { id: 'overview', label: 'Overview', icon: Activity, shortLabel: 'Overview' }, + { id: 'earnings', label: 'Earnings', icon: DollarSign, shortLabel: 'Earnings' }, + { id: 'telemetry', label: 'Telemetry', icon: BarChart3, shortLabel: 'KPIs' }, + { id: 'users', label: 'Users', icon: Users, shortLabel: 'Users' }, + { id: 'newsletter', label: 'Newsletter', icon: Mail, shortLabel: 'News' }, + { id: 'tld', label: 'TLD Data', icon: Globe, shortLabel: 'TLD' }, + { id: 'auctions', label: 'Auctions', icon: Gavel, shortLabel: 'Auctions' }, + { id: 'system', label: 'System', icon: Database, shortLabel: 'System' }, + { id: 'activity', label: 'Activity', icon: History, shortLabel: 'Log' }, +] + +// ============================================================================ +// MAIN PAGE +// ============================================================================ + export default function AdminPage() { + const router = useRouter() + const { user, isAuthenticated, isLoading, checkAuth, logout } = useStore() + const [activeTab, setActiveTab] = useState('overview') + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + + // Data states const [stats, setStats] = useState(null) const [telemetryDays, setTelemetryDays] = useState(30) const [telemetry, setTelemetry] = useState(null) const [referrals, setReferrals] = useState(null) const [users, setUsers] = useState([]) const [usersTotal, setUsersTotal] = useState(0) - // Selection removed - PremiumTable doesn't support it const [newsletter, setNewsletter] = useState([]) const [newsletterTotal, setNewsletterTotal] = useState(0) const [priceAlerts, setPriceAlerts] = useState([]) @@ -86,22 +127,49 @@ export default function AdminPage() { const [schedulerStatus, setSchedulerStatus] = useState(null) const [systemHealth, setSystemHealth] = useState(null) const [backups, setBackups] = useState([]) - const [backingUp, setBackingUp] = useState(false) - const [runningOpsAlerts, setRunningOpsAlerts] = useState(false) - const [opsAlertsHistory, setOpsAlertsHistory] = useState([]) + + // UI states const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) const [searchQuery, setSearchQuery] = useState('') + + // Action states const [scraping, setScraping] = useState(false) const [auctionScraping, setAuctionScraping] = useState(false) const [domainChecking, setDomainChecking] = useState(false) const [sendingEmail, setSendingEmail] = useState(false) - const [bulkTier, setBulkTier] = useState('trader') + const [backingUp, setBackingUp] = useState(false) + const [runningOpsAlerts, setRunningOpsAlerts] = useState(false) + const [opsAlertsHistory, setOpsAlertsHistory] = useState([]) + + // Auth check + useEffect(() => { + checkAuth() + }, [checkAuth]) useEffect(() => { - loadAdminData() - }, [activeTab]) + if (!isLoading && !isAuthenticated) { + router.push('/login') + } + }, [isLoading, isAuthenticated, router]) + + useEffect(() => { + const saved = localStorage.getItem('admin-sidebar-collapsed') + if (saved) setSidebarCollapsed(saved === 'true') + }, []) + + // Load data when tab changes + useEffect(() => { + if (!user?.is_admin) return + loadAdminData() + }, [activeTab, user?.is_admin]) + + const toggleSidebar = () => { + const newState = !sidebarCollapsed + setSidebarCollapsed(newState) + localStorage.setItem('admin-sidebar-collapsed', String(newState)) + } const loadAdminData = async () => { setLoading(true) @@ -131,32 +199,33 @@ export default function AdminPage() { setNewsletter(nlData.subscribers) setNewsletterTotal(nlData.total) } else if (activeTab === 'system') { - const [healthData, schedulerData] = await Promise.all([ + const [healthData, schedulerData] = await Promise.all([ api.getSystemHealth().catch(() => null), api.getSchedulerStatus().catch(() => null), - ]) - setSystemHealth(healthData) - setSchedulerStatus(schedulerData) - const backupData = await api.listDbBackups(20).catch(() => ({ backups: [] })) - setBackups(backupData.backups || []) - const opsHistory = await api.getOpsAlertsHistory(50).catch(() => ({ events: [] })) - setOpsAlertsHistory(opsHistory.events || []) + ]) + setSystemHealth(healthData) + setSchedulerStatus(schedulerData) + const backupData = await api.listDbBackups(20).catch(() => ({ backups: [] })) + setBackups(backupData.backups || []) + const opsHistory = await api.getOpsAlertsHistory(50).catch(() => ({ events: [] })) + setOpsAlertsHistory(opsHistory.events || []) } else if (activeTab === 'activity') { const logData = await api.getActivityLog(50, 0).catch(() => ({ logs: [], total: 0 })) - setActivityLog(logData.logs) - setActivityLogTotal(logData.total) + setActivityLog(logData.logs) + setActivityLogTotal(logData.total) } else if (activeTab === 'blog') { const blogData = await api.getAdminBlogPosts(50, 0).catch(() => ({ posts: [], total: 0 })) - setBlogPosts(blogData.posts) - setBlogPostsTotal(blogData.total) + setBlogPosts(blogData.posts) + setBlogPostsTotal(blogData.total) } } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load admin data') + setError(err instanceof Error ? err.message : 'Failed to load admin data') } finally { setLoading(false) } } + // Actions const handleTriggerScrape = async () => { setScraping(true) try { @@ -282,96 +351,304 @@ export default function AdminPage() { } } + // Loading state + if (isLoading) { return ( - setActiveTab(tab as TabType)} - > - +
+
+ +

Loading...

+
+
+ ) + } + + // Access denied + if (!isAuthenticated || !user?.is_admin) { + return ( +
+
+ +

Access Denied

+

Admin privileges required

+ +
+
+ ) + } + + return ( +
+ {/* Background Effects */} +
+
+
+
+ + {/* Mobile Menu Button */} + + + {/* Mobile Overlay */} + {mobileMenuOpen && ( +
setMobileMenuOpen(false)} + /> + )} + + {/* Sidebar */} + + + {/* Main Content */} +
+ {/* Header */} +
+
+
+
+
+ Admin Panel +
+

+ {TABS.find(t => t.id === activeTab)?.label || 'Admin'} +

+
+
+
+ + {/* Page Content */} +
{/* Messages */} - {error && ( -
- -

{error}

- + {error && ( +
+ +

{error}

+
)} {success && ( -
+
-

{success}

- +

{success}

+
)} - {loading ? ( + {/* Tab Content */} + {loading && activeTab !== 'earnings' ? (
- +
) : ( <> {/* Overview Tab */} {activeTab === 'overview' && stats && ( -
-
+
+
- - - + + +
-
- {[ - { tier: 'scout', icon: Zap, color: 'text-foreground-muted' }, - { tier: 'trader', icon: TrendingUp, color: 'text-accent' }, - { tier: 'tycoon', icon: Crown, color: 'text-amber-400' }, - ].map(({ tier, icon: Icon, color }) => ( -
-
- - {tier} +
+ {[ + { tier: 'scout', icon: Zap, color: 'text-white/40', bg: 'bg-white/5', border: 'border-white/10' }, + { tier: 'trader', icon: TrendingUp, color: 'text-accent', bg: 'bg-accent/10', border: 'border-accent/20' }, + { tier: 'tycoon', icon: Crown, color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/20' }, + ].map(({ tier, icon: Icon, color, bg, border }) => ( +
+
+ + {tier}
-

{stats.subscriptions[tier] || 0}

+

{stats.subscriptions[tier] || 0}

- ))} + ))}
-
-
-

Active Auctions

-

{stats.auctions.toLocaleString()}

+
+
+

Active Auctions

+

{stats.auctions.toLocaleString()}

-
-

Price Alerts

-

{stats.price_alerts.toLocaleString()}

+
+

Price Alerts

+

{stats.price_alerts.toLocaleString()}

)} + {/* Earnings Tab */} + {activeTab === 'earnings' && } + {/* Telemetry Tab */} {activeTab === 'telemetry' && telemetry && (
- - Window + + Window
-
- {telemetry.window?.start ? new Date(telemetry.window.start).toLocaleDateString() : '—'} →{' '} - {telemetry.window?.end ? new Date(telemetry.window.end).toLocaleDateString() : '—'} -
@@ -425,586 +698,283 @@ export default function AdminPage() { icon={Wallet} />
- - {referrals ? ( -
-
-
-

Viral Loop

-

Referrals

-
-
- {referrals.window?.start ? new Date(referrals.window.start).toLocaleDateString() : '—'} →{' '} - {referrals.window?.end ? new Date(referrals.window.end).toLocaleDateString() : '—'} -
-
- -
- - - - -
- - r.user_id} - compact - columns={[ - { - key: 'email', - header: 'Referrer', - render: (r: any) => ( -
-
{r.email}
-
{r.invite_code || '—'}
-
- ), - }, - { - key: 'referred_users_window', - header: `Signups (${telemetryDays}d)`, - align: 'right', - render: (r: any) => {r.referred_users_window || 0}, - }, - { - key: 'referred_users_total', - header: 'All-time', - align: 'right', - render: (r: any) => {r.referred_users_total || 0}, - }, - { - key: 'referral_link_views_window', - header: 'Link views', - align: 'right', - hideOnMobile: true, - render: (r: any) => ( - {r.referral_link_views_window || 0} - ), - }, - ]} - emptyTitle="No referral data" - emptyDescription="Invite codes exist, but no signups were attributed in this window." - /> -
- ) : null} - -
-

Latency (Median)

-
-
-

Inquiry → Seller Reply

-

- {telemetry.deal?.median_reply_seconds ? `${Math.round(telemetry.deal.median_reply_seconds / 60)}m` : '—'} -

-
-
-

Inquiry → Sold

-

- {telemetry.deal?.median_time_to_sold_seconds ? `${Math.round(telemetry.deal.median_time_to_sold_seconds / 3600)}h` : '—'} -

-
-
-
)} {/* Users Tab */} {activeTab === 'users' && ( -
+
- + setSearchQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && loadAdminData()} placeholder="Search users..." - className="w-full pl-11 pr-4 py-3 bg-background-secondary/50 border border-border/30 rounded-xl text-sm" + className="w-full pl-11 pr-4 py-3 bg-white/5 border border-white/10 text-white text-sm font-mono placeholder:text-white/30 focus:border-red-500/50 focus:outline-none" />
-
- u.id} - columns={[ - { - key: 'user', - header: 'User', - render: (u) => ( -
-

{u.email}

-

{u.name || 'No name'}

-
- ), - }, - { - key: 'status', - header: 'Status', - hideOnMobile: true, - render: (u) => ( -
- {u.is_admin && Admin} - {u.is_verified && Verified} - {!u.is_active && Inactive} -
- ), - }, - { - key: 'tier', - header: 'Tier', - render: (u) => ( - - {u.subscription.tier_name} - - ), - }, - { - key: 'domains', - header: 'Domains', - hideOnMobile: true, - render: (u) => {u.domain_count}, - }, - { - key: 'actions', - header: 'Actions', - align: 'right', - render: (u) => ( -
- - handleToggleAdmin(u.id, u.is_admin)} variant={u.is_admin ? 'accent' : 'default'} title={u.is_admin ? 'Remove admin' : 'Make admin'} /> - handleDeleteUser(u.id, u.email)} variant="danger" disabled={u.is_admin} title="Delete user" /> -
- ), - }, - ]} - emptyIcon={} - emptyTitle="No users found" - /> -

Showing {users.length} of {usersTotal} users

+ u.id} + columns={[ + { + key: 'user', + header: 'User', + render: (u) => ( +
+

{u.email}

+

{u.name || 'No name'}

+
+ ), + }, + { + key: 'status', + header: 'Status', + hideOnMobile: true, + render: (u) => ( +
+ {u.is_admin && Admin} + {u.is_verified && Verified} + {!u.is_active && Inactive} +
+ ), + }, + { + key: 'tier', + header: 'Tier', + render: (u) => ( + + {u.subscription.tier_name} + + ), + }, + { + key: 'domains', + header: 'Domains', + hideOnMobile: true, + render: (u) => {u.domain_count}, + }, + { + key: 'actions', + header: 'Actions', + align: 'right', + render: (u) => ( +
+ + handleToggleAdmin(u.id, u.is_admin)} variant={u.is_admin ? 'accent' : 'default'} title={u.is_admin ? 'Remove admin' : 'Make admin'} /> + handleDeleteUser(u.id, u.email)} variant="danger" disabled={u.is_admin} title="Delete user" /> +
+ ), + }, + ]} + emptyIcon={} + emptyTitle="No users found" + /> +

Showing {users.length} of {usersTotal} users

)} {/* Newsletter Tab */} {activeTab === 'newsletter' && ( -
+
-

{newsletterTotal} subscribers

+

{newsletterTotal} subscribers

- s.id} - columns={[ - { key: 'email', header: 'Email', render: (s) => {s.email} }, - { key: 'status', header: 'Status', render: (s) => {s.is_active ? 'Active' : 'Unsubscribed'} }, - { key: 'subscribed', header: 'Subscribed', render: (s) => new Date(s.subscribed_at).toLocaleDateString() }, - ]} - /> + s.id} + columns={[ + { key: 'email', header: 'Email', render: (s) => {s.email} }, + { key: 'status', header: 'Status', render: (s) => {s.is_active ? 'Active' : 'Unsubscribed'} }, + { key: 'subscribed', header: 'Subscribed', render: (s) => {new Date(s.subscribed_at).toLocaleDateString()} }, + ]} + />
)} {/* System Tab */} {activeTab === 'system' && ( -
-
-

System Status

-
- {[ - { label: 'Database', ok: systemHealth?.database === 'healthy', text: systemHealth?.database || 'Unknown' }, - { label: 'Email (SMTP)', ok: systemHealth?.email_configured, text: systemHealth?.email_configured ? 'Configured' : 'Not configured' }, - { label: 'Stripe', ok: systemHealth?.stripe_configured, text: systemHealth?.stripe_configured ? 'Configured' : 'Not configured' }, - { label: 'Scheduler', ok: schedulerStatus?.scheduler_running, text: schedulerStatus?.scheduler_running ? 'Running' : 'Stopped' }, - ].map((item) => ( -
- {item.label} - - {item.ok ? : } - {item.text} - -
- ))} +
+
+
+

System Status

+
+
+ {[ + { label: 'Database', ok: systemHealth?.database === 'healthy', text: systemHealth?.database || 'Unknown' }, + { label: 'Email (SMTP)', ok: systemHealth?.email_configured, text: systemHealth?.email_configured ? 'Configured' : 'Not configured' }, + { label: 'Stripe', ok: systemHealth?.stripe_configured, text: systemHealth?.stripe_configured ? 'Configured' : 'Not configured' }, + { label: 'Scheduler', ok: schedulerStatus?.scheduler_running, text: schedulerStatus?.scheduler_running ? 'Running' : 'Stopped' }, + ].map((item) => ( +
+ {item.label} + + {item.ok ? : } + {item.text} + +
+ ))} +
-
-
-
-

Manual Triggers

-
- - - - - - -
-
- -
-

Backups

- {!backups?.length ? ( -

No backups found.

- ) : ( -
- {backups.slice(0, 6).map((b: any) => ( -
-
-

{b.name}

-

{new Date(b.modified_at).toLocaleString()}

-
-
- {Math.round((b.size_bytes || 0) / 1024 / 1024)} MB -
-
- ))} + + + +
- )} -

- Backups run daily if enabled on server. Manual backup requires ENABLE_DB_BACKUPS=true. -

-
- -
-

Ops Alerts History

- {!opsAlertsHistory?.length ? ( -

No ops alert events yet.

- ) : ( -
- {opsAlertsHistory.slice(0, 8).map((e: any) => ( -
-
-
-

{e.title}

-

- {e.alert_key} · {e.status}{e.send_reason ? ` (${e.send_reason})` : ''} -

-
-
- {new Date(e.created_at).toLocaleString()} -
-
- {e.detail ? ( -

{e.detail}

- ) : null} -
- ))} -
- )} -

- Enable OPS_ALERTS_ENABLED=true and set OPS_ALERT_RECIPIENTS for email delivery. -

-
- - {schedulerStatus && ( -
-

Scheduled Jobs

-
- {schedulerStatus.jobs?.slice(0, 5).map((job: any) => ( -
- {job.name} - {job.trigger} -
- ))} -
+ +
+
+

Recent Backups

+
+
+ {!backups?.length ? ( +

No backups found.

+ ) : ( +
+ {backups.slice(0, 5).map((b: any) => ( +
+
+

{b.name}

+

{new Date(b.modified_at).toLocaleString()}

+
+
+ {Math.round((b.size_bytes || 0) / 1024 / 1024)} MB +
+
+ ))} +
)} +
+
+
+
+ )} + + {/* TLD Tab */} + {activeTab === 'tld' && stats && ( +
+
+ + + + +
+ +
+

TLD Price Management

+
+ +
)} {/* Auctions Tab */} - {activeTab === 'auctions' && ( -
- {/* Auction Stats */} -
-
-

Total Auctions

-

{stats?.auctions.toLocaleString() || 0}

-
-
-

Platforms

-

4

-

GoDaddy, Sedo, NameJet, DropCatch

+ {activeTab === 'auctions' && stats && ( +
+
+ + +
-
-

Clean Domains

-

- {stats?.auctions ? Math.round(stats.auctions * 0.4).toLocaleString() : 0} -

-

~40% pass vanity filter

-
-
-

Last Scraped

-

- {schedulerStatus?.jobs?.find((j: any) => j.name.includes('auction'))?.next_run_time - ? 'Recently' - : 'Unknown'} -

-
-
- {/* Auction Scraping Actions */} -
-

Auction Management

-
- - -
+
+ )} - {/* Platform Status */} -
-

Platform Status

-
- {['GoDaddy', 'Sedo', 'NameJet', 'DropCatch'].map((platform) => ( -
-
-
- -
- {platform} -
-
- - Active -
-
- ))} -
-
- - {/* Vanity Filter Info */} -
-

Vanity Filter (Public Page)

-

- Non-authenticated users only see "clean" domains that pass these rules: -

-
- {[ - { rule: 'No Hyphens', desc: 'domain-name.com ❌' }, - { rule: 'No Numbers', desc: 'domain123.com ❌ (unless ≤4 chars)' }, - { rule: 'Max 12 Chars', desc: 'verylongdomainname.com ❌' }, - { rule: 'Premium TLDs', desc: '.com .io .ai .co .net .org .app .dev .ch .de ✅' }, - ].map((item) => ( -
-

{item.rule}

-

{item.desc}

-
- ))} -
-
-
- )} - - {/* Alerts Tab */} - {activeTab === 'alerts' && ( - a.id} - emptyIcon={} - emptyTitle="No active price alerts" - columns={[ - { key: 'tld', header: 'TLD', render: (a) => .{a.tld} }, - { key: 'target', header: 'Target', render: (a) => a.target_price ? `$${a.target_price.toFixed(2)}` : '—' }, - { key: 'type', header: 'Type', render: (a) => {a.alert_type} }, - { key: 'user', header: 'User', render: (a) => a.user.email }, - { key: 'created', header: 'Created', hideOnMobile: true, render: (a) => new Date(a.created_at).toLocaleDateString() }, - ]} - /> - )} - + {/* Activity Tab */} {activeTab === 'activity' && ( - l.id} - emptyIcon={} - emptyTitle="No activity logged" - columns={[ - { key: 'action', header: 'Action', render: (l) => {l.action} }, - { key: 'details', header: 'Details', render: (l) => {l.details} }, - { key: 'admin', header: 'Admin', render: (l) => l.admin.email }, - { key: 'time', header: 'Time', hideOnMobile: true, render: (l) => new Date(l.created_at).toLocaleString() }, - ]} - /> - )} - - {/* TLD Pricing Tab */} - {activeTab === 'tld' && ( -
- {/* TLD Stats */} -
-
-

Unique TLDs

-

{stats?.tld_data?.unique_tlds?.toLocaleString() || 0}

-
-
-

Price Records

-

{stats?.tld_data?.price_records?.toLocaleString() || 0}

-
-
-

Active Price Alerts

-

{stats?.price_alerts || 0}

-
-
-

Renewal Traps

-

~40%

-

Have >2x renewal

-
-
- - {/* TLD Scraping Actions */} -
-

TLD Price Management

-
- - -
-
- - {/* New Features from analysis_4.md */} -
-

New TLD Pricing Features

-

- Implemented from analysis_4.md "Inflation Monitor" concept: -

-
- {[ - { feature: 'Renewal Trap Detection', status: '✅', desc: 'Warns when renewal >2x registration' }, - { feature: '1y/3y Trend Tracking', status: '✅', desc: 'Shows price changes over time' }, - { feature: 'Risk Level Badges', status: '✅', desc: 'Low, Medium, High indicators' }, - { feature: 'Category Tabs', status: '✅', desc: 'Tech, Geo, Budget, Premium' }, - { feature: 'Sparkline Charts', status: '✅', desc: 'Visual trend indicators' }, - { feature: 'Blur for Free Users', status: '✅', desc: 'First 5 rows visible' }, - ].map((item) => ( -
-
- {item.status} - {item.feature} -
-

{item.desc}

-
- ))} -
-
- - {/* Data Sources */} -
-

Data Sources

-
- {['Cloudflare', 'Namecheap', 'Porkbun', 'GoDaddy', 'Google Domains', 'Dynadot'].map((registrar) => ( -
-
-
- -
- {registrar} -
-
- - Active -
-
- ))} -
-
-
- )} - - {activeTab === 'blog' && ( -
-
-

{blogPostsTotal} posts

- -
p.id} - emptyIcon={} - emptyTitle="No blog posts" + data={activityLog} + keyExtractor={(l) => l.id} + emptyIcon={} + emptyTitle="No activity logged" columns={[ - { key: 'title', header: 'Title', render: (p) =>

{p.title}

{p.slug}

}, - { key: 'category', header: 'Category', hideOnMobile: true, render: (p) => p.category ? {p.category} : '—' }, - { key: 'status', header: 'Status', render: (p) => {p.is_published ? 'Published' : 'Draft'} }, - { key: 'views', header: 'Views', hideOnMobile: true, render: (p) => p.view_count }, - { key: 'actions', header: '', align: 'right', render: (p) => ( -
- window.open(`/blog/${p.slug}`, '_blank')} /> - - -
- ) }, + { key: 'action', header: 'Action', render: (l) => {l.action} }, + { key: 'details', header: 'Details', render: (l) => {l.details} }, + { key: 'admin', header: 'Admin', render: (l) => {l.admin.email} }, + { key: 'time', header: 'Time', hideOnMobile: true, render: (l) => {new Date(l.created_at).toLocaleString()} }, ]} /> -
- )} - - )} - - + )} + + )} +
+
+
) } diff --git a/frontend/src/components/admin/EarningsTab.tsx b/frontend/src/components/admin/EarningsTab.tsx new file mode 100644 index 0000000..c557e80 --- /dev/null +++ b/frontend/src/components/admin/EarningsTab.tsx @@ -0,0 +1,278 @@ +'use client' + +import { useEffect, useState } from 'react' +import { api } from '@/lib/api' +import { + DollarSign, + TrendingUp, + Users, + Crown, + Zap, + RefreshCw, + Loader2, + ArrowUpRight, + ArrowDownRight, + Coins, +} from 'lucide-react' +import clsx from 'clsx' + +interface EarningsData { + mrr: number + arr: number + paying_customers: number + tier_breakdown: { + scout: { count: number; revenue: number } + trader: { count: number; revenue: number } + tycoon: { count: number; revenue: number } + } + new_subscriptions: { week: number; month: number } + churn: { month: number } + yield_revenue_month: number + total_revenue_month: number + timestamp: string +} + +export function EarningsTab() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + + const loadData = async () => { + try { + const earnings = await api.getAdminEarnings() + setData(earnings) + } catch (err) { + console.error('Failed to load earnings:', err) + } finally { + setLoading(false) + setRefreshing(false) + } + } + + useEffect(() => { + loadData() + }, []) + + const handleRefresh = () => { + setRefreshing(true) + loadData() + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (!data) { + return ( +
+ +

Failed to load earnings data

+
+ ) + } + + return ( +
+ {/* Header Actions */} +
+
+

Revenue Dashboard

+

+ Last updated: {new Date(data.timestamp).toLocaleString()} +

+
+ +
+ + {/* Main Revenue Cards */} +
+ {/* MRR */} +
+
+
+
+
+ +
+ Monthly Recurring +
+
+ ${data.mrr.toLocaleString()} +
+
MRR
+
+
+ + {/* ARR */} +
+
+
+
+
+ +
+ Annual Recurring +
+
+ ${data.arr.toLocaleString()} +
+
ARR
+
+
+ + {/* Paying Customers */} +
+
+
+ +
+ Paying Customers +
+
+ {data.paying_customers} +
+
+ + + +{data.new_subscriptions.week} this week + +
+
+ + {/* Yield Revenue */} +
+
+
+ +
+ Yield Revenue +
+
+ ${data.yield_revenue_month.toFixed(0)} +
+
This month (30%)
+
+
+ + {/* Tier Breakdown */} +
+
+

Subscription Breakdown

+
+
+ {/* Tycoon */} +
+
+
+ +
+
+

Tycoon

+

$29/month

+
+
+
+

{data.tier_breakdown.tycoon.count}

+

${data.tier_breakdown.tycoon.revenue}/mo

+
+
+ + {/* Trader */} +
+
+
+ +
+
+

Trader

+

$9/month

+
+
+
+

{data.tier_breakdown.trader.count}

+

${data.tier_breakdown.trader.revenue}/mo

+
+
+ + {/* Scout */} +
+
+
+ +
+
+

Scout

+

Free

+
+
+
+

{data.tier_breakdown.scout.count}

+

$0/mo

+
+
+
+
+ + {/* Growth & Churn */} +
+ {/* New This Week */} +
+
+ + New This Week +
+

{data.new_subscriptions.week}

+

paid subscriptions

+
+ + {/* New This Month */} +
+
+ + New This Month +
+

{data.new_subscriptions.month}

+

paid subscriptions

+
+ + {/* Churn */} +
+
+ + Churned This Month +
+

{data.churn.month}

+

cancelled

+
+
+ + {/* Total Revenue Summary */} +
+
+
+

Total Monthly Revenue

+

Subscriptions + Yield (30%)

+
+
+

${data.total_revenue_month.toLocaleString()}

+

+ ${(data.total_revenue_month * 12).toLocaleString()} projected ARR +

+
+
+
+
+ ) +} + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 08e167f..c693c06 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1268,6 +1268,24 @@ class AdminApiClient extends ApiClient { return this.request('/admin/stats') } + async getAdminEarnings() { + return this.request<{ + mrr: number + arr: number + paying_customers: number + tier_breakdown: { + scout: { count: number; revenue: number } + trader: { count: number; revenue: number } + tycoon: { count: number; revenue: number } + } + new_subscriptions: { week: number; month: number } + churn: { month: number } + yield_revenue_month: number + total_revenue_month: number + timestamp: string + }>('/admin/earnings') + } + // Admin Users async getAdminUsers(limit: number = 50, offset: number = 0, search?: string) { const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })