Yves Gugger 3d6fc1f795 feat: Add user deletion in admin panel and fix OAuth authentication
- Add delete user functionality with cascade deletion of all user data
- Fix OAuth URLs to include /api/v1 path
- Fix token storage key consistency in OAuth callback
- Update user model to cascade delete price alerts
- Improve email templates with minimalist design
- Add confirmation dialog for user deletion
- Prevent deletion of admin users
2025-12-09 21:45:40 +01:00

1505 lines
73 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import {
Users,
Database,
Mail,
TrendingUp,
Shield,
RefreshCw,
Search,
Crown,
Zap,
Activity,
Globe,
Bell,
AlertCircle,
Check,
Loader2,
Trash2,
Eye,
Gavel,
CheckCircle,
XCircle,
Download,
Send,
Clock,
History,
X,
ChevronDown,
BookOpen,
Plus,
Edit2,
Trash2 as TrashIcon,
ExternalLink,
} from 'lucide-react'
import clsx from 'clsx'
type TabType = 'overview' | 'users' | 'alerts' | 'newsletter' | 'tld' | 'auctions' | 'blog' | 'system' | 'activity'
interface AdminStats {
users: {
total: number
active: number
verified: number
new_this_week: number
}
subscriptions: Record<string, number>
domains: {
watched: number
portfolio: number
}
tld_data: {
unique_tlds: number
price_records: number
}
newsletter_subscribers: number
auctions: number
price_alerts: number
}
interface AdminUser {
id: number
email: string
name: string | null
is_active: boolean
is_verified: boolean
is_admin: boolean
created_at: string
last_login: string | null
domain_count: number
subscription: {
tier: string
tier_name: string
status: string | null
domain_limit: number
}
}
interface NewsletterSubscriber {
id: number
email: string
is_active: boolean
subscribed_at: string
unsubscribed_at: string | null
}
interface PriceAlert {
id: number
tld: string
target_price: number | null
alert_type: string
created_at: string
user: { id: number; email: string; name: string | null }
}
interface ActivityLogEntry {
id: number
action: string
details: string
created_at: string
admin: { id: number; email: string; name: string | null }
}
interface BlogPostAdmin {
id: number
title: string
slug: string
excerpt: string | null
cover_image: string | null
category: string | null
tags: string[]
is_published: boolean
published_at: string | null
created_at: string
view_count: number
author: { id: number; name: string | null }
}
interface SchedulerJob {
id: string
name: string
next_run: string | null
trigger: string
}
export default function AdminPage() {
const router = useRouter()
const { user, isAuthenticated, isLoading, checkAuth } = useStore()
const [activeTab, setActiveTab] = useState<TabType>('overview')
const [stats, setStats] = useState<AdminStats | null>(null)
const [users, setUsers] = useState<AdminUser[]>([])
const [usersTotal, setUsersTotal] = useState(0)
const [selectedUsers, setSelectedUsers] = useState<number[]>([])
const [newsletter, setNewsletter] = useState<NewsletterSubscriber[]>([])
const [newsletterTotal, setNewsletterTotal] = useState(0)
const [priceAlerts, setPriceAlerts] = useState<PriceAlert[]>([])
const [priceAlertsTotal, setPriceAlertsTotal] = useState(0)
const [activityLog, setActivityLog] = useState<ActivityLogEntry[]>([])
const [activityLogTotal, setActivityLogTotal] = useState(0)
const [blogPosts, setBlogPosts] = useState<BlogPostAdmin[]>([])
const [blogPostsTotal, setBlogPostsTotal] = useState(0)
const [showBlogEditor, setShowBlogEditor] = useState(false)
const [editingPost, setEditingPost] = useState<BlogPostAdmin | null>(null)
const [blogForm, setBlogForm] = useState({
title: '',
content: '',
excerpt: '',
category: '',
tags: '',
cover_image: '',
is_published: false,
})
const [schedulerStatus, setSchedulerStatus] = useState<{
scheduler_running: boolean
jobs: SchedulerJob[]
last_runs: { tld_scrape: string | null; auction_scrape: string | null; domain_check: string | null }
} | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [scraping, setScraping] = useState(false)
const [auctionScraping, setAuctionScraping] = useState(false)
const [domainChecking, setDomainChecking] = useState(false)
const [sendingEmail, setSendingEmail] = useState(false)
const [auctionStatus, setAuctionStatus] = useState<any>(null)
const [systemHealth, setSystemHealth] = useState<any>(null)
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [bulkTier, setBulkTier] = useState('trader')
useEffect(() => {
checkAuth()
}, [checkAuth])
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login')
}
}, [isLoading, isAuthenticated, router])
useEffect(() => {
if (isAuthenticated) {
loadAdminData()
}
}, [isAuthenticated, activeTab])
const loadAdminData = async () => {
setLoading(true)
setError(null)
try {
if (activeTab === 'overview') {
const statsData = await api.getAdminStats()
setStats(statsData)
} else if (activeTab === 'users') {
const usersData = await api.getAdminUsers(50, 0, searchQuery || undefined)
setUsers(usersData.users)
setUsersTotal(usersData.total)
} else if (activeTab === 'alerts') {
const alertsData = await api.getAdminPriceAlerts(100, 0)
setPriceAlerts(alertsData.alerts)
setPriceAlertsTotal(alertsData.total)
} else if (activeTab === 'newsletter') {
const nlData = await api.getAdminNewsletter(100, 0)
setNewsletter(nlData.subscribers)
setNewsletterTotal(nlData.total)
} else if (activeTab === 'auctions') {
try {
const statusData = await api.getAuctionScrapeStatus()
setAuctionStatus(statusData)
} catch {
setAuctionStatus(null)
}
} else if (activeTab === 'system') {
try {
const [healthData, schedulerData] = await Promise.all([
api.getSystemHealth(),
api.getSchedulerStatus(),
])
setSystemHealth(healthData)
setSchedulerStatus(schedulerData)
} catch {
setSystemHealth(null)
setSchedulerStatus(null)
}
} else if (activeTab === 'activity') {
try {
const logData = await api.getActivityLog(50, 0)
setActivityLog(logData.logs)
setActivityLogTotal(logData.total)
} catch {
setActivityLog([])
setActivityLogTotal(0)
}
} else if (activeTab === 'blog') {
try {
const blogData = await api.getAdminBlogPosts(50, 0)
setBlogPosts(blogData.posts)
setBlogPostsTotal(blogData.total)
} catch {
setBlogPosts([])
setBlogPostsTotal(0)
}
}
} catch (err) {
if (err instanceof Error && err.message.includes('403')) {
setError('Admin privileges required. You are not authorized to access this page.')
} else {
setError(err instanceof Error ? err.message : 'Failed to load admin data')
}
} finally {
setLoading(false)
}
}
const handleTriggerScrape = async () => {
setScraping(true)
setError(null)
try {
const result = await api.triggerTldScrape()
setSuccess(`Scrape completed: ${result.tlds_scraped} TLDs, ${result.prices_saved} prices saved`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Scrape failed')
} finally {
setScraping(false)
}
}
const handleTriggerAuctionScrape = async () => {
setAuctionScraping(true)
setError(null)
try {
const result = await api.triggerAuctionScrape()
setSuccess(`Auction scrape completed: ${result.total_auctions || 0} auctions found`)
loadAdminData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Auction scrape failed')
} finally {
setAuctionScraping(false)
}
}
const handleTriggerDomainChecks = async () => {
setDomainChecking(true)
setError(null)
try {
const result = await api.triggerDomainChecks()
setSuccess(`Domain checks started: ${result.domains_queued} domains queued`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Domain check failed')
} finally {
setDomainChecking(false)
}
}
const handleSendTestEmail = async () => {
setSendingEmail(true)
setError(null)
try {
const result = await api.sendTestEmail()
setSuccess(`Test email sent to ${result.sent_to}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Email test failed')
} finally {
setSendingEmail(false)
}
}
const handleUpgradeUser = async (userId: number, tier: string) => {
try {
await api.upgradeUser(userId, tier)
setSuccess(`User upgraded to ${tier}`)
loadAdminData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Upgrade failed')
}
}
const handleToggleAdmin = async (userId: number, isAdmin: boolean) => {
try {
await api.updateAdminUser(userId, { is_admin: !isAdmin })
setSuccess(isAdmin ? 'Admin privileges removed' : 'Admin privileges granted')
loadAdminData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Update failed')
}
}
const handleDeleteUser = async (userId: number, userEmail: string) => {
if (!confirm(`Are you sure you want to delete user "${userEmail}" and ALL their data?\n\nThis action cannot be undone.`)) {
return
}
try {
await api.deleteAdminUser(userId)
setSuccess(`User ${userEmail} and all their data have been deleted`)
setSelectedUser(null)
loadAdminData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Delete failed')
}
}
const handleBulkUpgrade = async () => {
if (selectedUsers.length === 0) {
setError('Please select users to upgrade')
return
}
try {
const result = await api.bulkUpgradeUsers(selectedUsers, bulkTier)
setSuccess(`Upgraded ${result.total_upgraded} users to ${bulkTier}`)
setSelectedUsers([])
loadAdminData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Bulk upgrade failed')
}
}
const handleExportUsers = async () => {
try {
const result = await api.exportUsersCSV()
const blob = new Blob([result.csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `users-export-${new Date().toISOString().split('T')[0]}.csv`
a.click()
setSuccess(`Exported ${result.count} users`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Export failed')
}
}
const toggleUserSelection = (userId: number) => {
setSelectedUsers(prev =>
prev.includes(userId)
? prev.filter(id => id !== userId)
: [...prev, userId]
)
}
const toggleSelectAll = () => {
if (selectedUsers.length === users.length) {
setSelectedUsers([])
} else {
setSelectedUsers(users.map(u => u.id))
}
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error && error.includes('403')) {
return (
<div className="min-h-screen bg-background flex flex-col">
<Header />
<main className="flex-1 flex items-center justify-center px-4">
<div className="text-center max-w-md">
<Shield className="w-16 h-16 text-danger mx-auto mb-6" />
<h1 className="text-2xl font-bold text-foreground mb-4">Access Denied</h1>
<p className="text-foreground-muted mb-6">
You don&apos;t have admin privileges to access this page.
</p>
<button
onClick={() => router.push('/dashboard')}
className="px-6 py-3 bg-foreground text-background rounded-lg font-medium"
>
Go to Dashboard
</button>
</div>
</main>
<Footer />
</div>
)
}
const tabs = [
{ id: 'overview' as const, label: 'Overview', icon: Activity },
{ id: 'users' as const, label: 'Users', icon: Users },
{ id: 'alerts' as const, label: 'Price Alerts', icon: Bell },
{ id: 'newsletter' as const, label: 'Newsletter', icon: Mail },
{ id: 'tld' as const, label: 'TLD Data', icon: Globe },
{ id: 'auctions' as const, label: 'Auctions', icon: Gavel },
{ id: 'blog' as const, label: 'Blog', icon: BookOpen },
{ id: 'system' as const, label: 'System', icon: Database },
{ id: 'activity' as const, label: 'Activity', icon: History },
]
return (
<div className="min-h-screen bg-background relative overflow-hidden">
{/* Background Effects */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
</div>
<Header />
<main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-12 animate-fade-in">
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Admin Panel</span>
<h1 className="mt-4 font-display text-[2rem] sm:text-[2.75rem] md:text-[3.5rem] leading-[1.1] tracking-[-0.03em] text-foreground">
Control Center.
</h1>
<p className="mt-3 text-lg text-foreground-muted">
Manage users, monitor system health, and control platform settings.
</p>
</div>
{/* Messages */}
{error && !error.includes('403') && (
<div className="mb-6 p-4 bg-danger-muted border border-danger/20 rounded-xl flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-danger shrink-0" />
<p className="text-body-sm text-danger flex-1">{error}</p>
<button onClick={() => setError(null)} className="text-danger hover:text-danger/80">
<X className="w-4 h-4" />
</button>
</div>
)}
{success && (
<div className="mb-6 p-4 bg-accent-muted border border-accent/20 rounded-xl flex items-center gap-3">
<Check className="w-5 h-5 text-accent shrink-0" />
<p className="text-body-sm text-accent flex-1">{success}</p>
<button onClick={() => setSuccess(null)} className="text-accent hover:text-accent/80">
<X className="w-4 h-4" />
</button>
</div>
)}
{/* Tabs */}
<div className="flex flex-wrap items-center gap-2 p-2 bg-background-secondary/50 backdrop-blur-sm border border-border rounded-2xl w-fit mb-12">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-xl whitespace-nowrap transition-all duration-300",
activeTab === tab.id
? "bg-accent text-background shadow-lg shadow-accent/20"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
) : (
<>
{/* Overview Tab */}
{activeTab === 'overview' && stats && (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard title="Total Users" value={stats.users.total} subtitle={`${stats.users.new_this_week} new this week`} icon={Users} />
<StatCard title="Watched Domains" value={stats.domains.watched} subtitle={`${stats.domains.portfolio} in portfolios`} icon={Eye} />
<StatCard title="TLDs Tracked" value={stats.tld_data.unique_tlds} subtitle={`${stats.tld_data.price_records.toLocaleString()} price records`} icon={Globe} />
<StatCard title="Newsletter" value={stats.newsletter_subscribers} subtitle="active subscribers" icon={Mail} />
</div>
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
<h3 className="text-body-lg font-medium text-foreground mb-4">Subscriptions by Tier</h3>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-background-tertiary rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Zap className="w-4 h-4 text-foreground-subtle" />
<span className="text-ui-sm text-foreground-muted">Scout</span>
</div>
<p className="text-2xl font-bold text-foreground">{stats.subscriptions.scout || 0}</p>
</div>
<div className="p-4 bg-background-tertiary rounded-lg">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-4 h-4 text-accent" />
<span className="text-ui-sm text-foreground-muted">Trader</span>
</div>
<p className="text-2xl font-bold text-foreground">{stats.subscriptions.trader || 0}</p>
</div>
<div className="p-4 bg-background-tertiary rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Crown className="w-4 h-4 text-warning" />
<span className="text-ui-sm text-foreground-muted">Tycoon</span>
</div>
<p className="text-2xl font-bold text-foreground">{stats.subscriptions.tycoon || 0}</p>
</div>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
<h3 className="text-body-lg font-medium text-foreground mb-4">Active Auctions</h3>
<p className="text-3xl font-display text-foreground">{stats.auctions.toLocaleString()}</p>
<p className="text-sm text-foreground-subtle mt-1">from all platforms</p>
</div>
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
<h3 className="text-body-lg font-medium text-foreground mb-4">Price Alerts</h3>
<p className="text-3xl font-display text-foreground">{stats.price_alerts.toLocaleString()}</p>
<p className="text-sm text-foreground-subtle mt-1">active alerts</p>
</div>
</div>
</div>
)}
{/* Users Tab */}
{activeTab === 'users' && (
<div className="space-y-6 animate-fade-in">
<div className="flex flex-wrap items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && loadAdminData()}
placeholder="Search users..."
className="w-full pl-11 pr-4 py-2.5 bg-background-secondary border border-border rounded-xl text-body-sm text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50"
/>
</div>
<button
onClick={handleExportUsers}
className="flex items-center gap-2 px-4 py-2.5 bg-background-secondary border border-border rounded-xl text-body-sm text-foreground hover:bg-foreground/5 transition-all"
>
<Download className="w-4 h-4" />
Export CSV
</button>
</div>
{selectedUsers.length > 0 && (
<div className="flex items-center gap-4 p-4 bg-accent/10 border border-accent/20 rounded-xl">
<span className="text-body-sm text-accent">{selectedUsers.length} users selected</span>
<div className="flex items-center gap-2 ml-auto">
<select
value={bulkTier}
onChange={(e) => setBulkTier(e.target.value)}
className="px-3 py-1.5 bg-background border border-border rounded-lg text-ui-sm"
>
<option value="scout">Scout</option>
<option value="trader">Trader</option>
<option value="tycoon">Tycoon</option>
</select>
<button
onClick={handleBulkUpgrade}
className="px-4 py-1.5 bg-accent text-background rounded-lg text-ui-sm font-medium"
>
Upgrade Selected
</button>
<button
onClick={() => setSelectedUsers([])}
className="px-3 py-1.5 text-foreground-muted hover:text-foreground"
>
Clear
</button>
</div>
</div>
)}
<div className="border border-border rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-background-secondary/50 border-b border-border">
<th className="text-left px-4 py-3">
<input
type="checkbox"
checked={selectedUsers.length === users.length && users.length > 0}
onChange={toggleSelectAll}
className="w-4 h-4 rounded border-border"
/>
</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">User</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted hidden md:table-cell">Status</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Tier</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted hidden lg:table-cell">Domains</th>
<th className="text-right px-4 py-3 text-ui-sm font-medium text-foreground-muted">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{users.map((u) => (
<tr key={u.id} className="hover:bg-background-secondary/30">
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedUsers.includes(u.id)}
onChange={() => toggleUserSelection(u.id)}
className="w-4 h-4 rounded border-border"
/>
</td>
<td className="px-4 py-3">
<button
onClick={() => setSelectedUser(u)}
className="text-left hover:text-accent transition-colors"
>
<p className="text-body-sm font-medium text-foreground">{u.email}</p>
<p className="text-ui-sm text-foreground-subtle">{u.name || 'No name'}</p>
</button>
</td>
<td className="px-4 py-3 hidden md:table-cell">
<div className="flex items-center gap-2">
{u.is_admin && <span className="px-2 py-0.5 bg-accent/20 text-accent text-ui-xs rounded-full">Admin</span>}
{u.is_verified && <span className="px-2 py-0.5 bg-accent-muted text-accent text-ui-xs rounded-full">Verified</span>}
{!u.is_active && <span className="px-2 py-0.5 bg-danger-muted text-danger text-ui-xs rounded-full">Inactive</span>}
</div>
</td>
<td className="px-4 py-3">
<span className={clsx(
"px-2 py-1 text-ui-xs font-medium rounded-lg",
u.subscription.tier === 'tycoon' ? "bg-warning/20 text-warning" :
u.subscription.tier === 'trader' ? "bg-accent/20 text-accent" :
"bg-background-tertiary text-foreground-muted"
)}>
{u.subscription.tier_name}
</span>
</td>
<td className="px-4 py-3 hidden lg:table-cell">
<span className="text-body-sm text-foreground-muted">{u.domain_count}</span>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<select
value={u.subscription.tier}
onChange={(e) => handleUpgradeUser(u.id, e.target.value)}
className="px-2 py-1 bg-background-secondary border border-border rounded-lg text-ui-xs text-foreground"
>
<option value="scout">Scout</option>
<option value="trader">Trader</option>
<option value="tycoon">Tycoon</option>
</select>
<button
onClick={() => handleToggleAdmin(u.id, u.is_admin)}
className={clsx(
"p-1.5 rounded-lg transition-colors",
u.is_admin ? "bg-accent/20 text-accent" : "bg-background-tertiary text-foreground-subtle hover:text-foreground"
)}
title={u.is_admin ? 'Remove admin' : 'Make admin'}
>
<Shield className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteUser(u.id, u.email)}
disabled={u.is_admin}
className="p-1.5 rounded-lg bg-danger/10 text-danger hover:bg-danger/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title={u.is_admin ? 'Cannot delete admin users' : 'Delete user and all data'}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<p className="text-ui-sm text-foreground-subtle">Showing {users.length} of {usersTotal} users</p>
</div>
)}
{/* Price Alerts Tab */}
{activeTab === 'alerts' && (
<div className="space-y-6 animate-fade-in">
<p className="text-body-sm text-foreground-muted">{priceAlertsTotal} active price alerts</p>
{priceAlerts.length === 0 ? (
<div className="p-12 bg-background-secondary/50 border border-border rounded-xl text-center">
<Bell className="w-12 h-12 text-foreground-subtle mx-auto mb-4" />
<p className="text-foreground-muted">No active price alerts</p>
</div>
) : (
<div className="border border-border rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-background-secondary/50 border-b border-border">
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">TLD</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Target Price</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Type</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">User</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Created</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{priceAlerts.map((alert) => (
<tr key={alert.id} className="hover:bg-background-secondary/30">
<td className="px-4 py-3 font-mono text-accent">.{alert.tld}</td>
<td className="px-4 py-3 text-foreground">
{alert.target_price ? `$${alert.target_price.toFixed(2)}` : '—'}
</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-background-tertiary text-foreground-muted text-ui-xs rounded-full">
{alert.alert_type}
</span>
</td>
<td className="px-4 py-3 text-body-sm text-foreground">{alert.user.email}</td>
<td className="px-4 py-3 text-body-sm text-foreground-muted">
{new Date(alert.created_at).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Newsletter Tab */}
{activeTab === 'newsletter' && (
<div className="space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<p className="text-body-sm text-foreground-muted">{newsletterTotal} total subscribers</p>
<button
onClick={async () => {
try {
const data = await api.exportNewsletterEmails()
const blob = new Blob([data.emails.join('\n')], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'newsletter-emails.txt'
a.click()
} catch (err) {
setError('Export failed')
}
}}
className="px-4 py-2 bg-accent text-background rounded-lg text-ui-sm font-medium hover:bg-accent-hover transition-all"
>
Export Emails
</button>
</div>
<div className="border border-border rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-background-secondary/50 border-b border-border">
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Email</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Status</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Subscribed</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{newsletter.map((s) => (
<tr key={s.id} className="hover:bg-background-secondary/30">
<td className="px-4 py-3 text-body-sm text-foreground">{s.email}</td>
<td className="px-4 py-3">
<span className={clsx(
"px-2 py-0.5 text-ui-xs rounded-full",
s.is_active ? "bg-accent-muted text-accent" : "bg-danger-muted text-danger"
)}>
{s.is_active ? 'Active' : 'Unsubscribed'}
</span>
</td>
<td className="px-4 py-3 text-body-sm text-foreground-muted">
{new Date(s.subscribed_at).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* TLD Tab */}
{activeTab === 'tld' && (
<div className="space-y-6 animate-fade-in">
<div className="grid md:grid-cols-2 gap-6">
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
<h3 className="text-body-lg font-medium text-foreground mb-4">TLD Price Data</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-foreground-muted">Unique TLDs</span>
<span className="font-medium text-foreground">{stats?.tld_data.unique_tlds || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-foreground-muted">Price Records</span>
<span className="font-medium text-foreground">{stats?.tld_data.price_records?.toLocaleString() || 0}</span>
</div>
</div>
</div>
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
<h3 className="text-body-lg font-medium text-foreground mb-4">Scrape TLD Prices</h3>
<p className="text-body-sm text-foreground-muted mb-4">Manually trigger a TLD price scrape.</p>
<button
onClick={handleTriggerScrape}
disabled={scraping}
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-background rounded-lg font-medium hover:bg-accent-hover disabled:opacity-50 transition-all"
>
{scraping ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{scraping ? 'Scraping...' : 'Start Scrape'}
</button>
</div>
</div>
</div>
)}
{/* Auctions Tab */}
{activeTab === 'auctions' && (
<div className="space-y-6 animate-fade-in">
<div className="grid md:grid-cols-2 gap-6">
<div className="p-6 bg-background-secondary/50 border border-border rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">Auction Data</h3>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-foreground-muted">Total Auctions</span>
<span className="text-xl font-display text-foreground">{stats?.auctions || 0}</span>
</div>
</div>
</div>
<div className="p-6 bg-background-secondary/50 border border-border rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">Scrape Auctions</h3>
<p className="text-sm text-foreground-muted mb-4">Scrape from GoDaddy, Sedo, NameJet, DropCatch.</p>
<button
onClick={handleTriggerAuctionScrape}
disabled={auctionScraping}
className="flex items-center gap-2 px-5 py-3 bg-accent text-background rounded-xl font-medium hover:bg-accent/90 disabled:opacity-50 transition-all"
>
{auctionScraping ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{auctionScraping ? 'Scraping...' : 'Start Auction Scrape'}
</button>
</div>
</div>
<div className="p-6 bg-background-secondary/50 border border-border rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">Platforms</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{['GoDaddy', 'Sedo', 'NameJet', 'DropCatch'].map((platform) => (
<div key={platform} className="p-4 bg-background-tertiary rounded-xl">
<div className="flex items-center gap-2 mb-1">
<span className="w-2 h-2 bg-accent rounded-full"></span>
<span className="text-sm font-medium text-foreground">{platform}</span>
</div>
<p className="text-xs text-foreground-subtle">Active</p>
</div>
))}
</div>
</div>
</div>
)}
{/* System Tab */}
{activeTab === 'system' && (
<div className="space-y-6 animate-fade-in">
<div className="p-6 bg-background-secondary/50 border border-border rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">System Status</h3>
<div className="space-y-3">
<div className="flex items-center justify-between py-3 border-b border-border">
<span className="text-foreground-muted">Database</span>
<span className="flex items-center gap-2">
{systemHealth?.database === 'healthy' ? (
<><CheckCircle className="w-4 h-4 text-accent" /><span className="text-accent">Healthy</span></>
) : (
<><XCircle className="w-4 h-4 text-danger" /><span className="text-danger">{systemHealth?.database || 'Unknown'}</span></>
)}
</span>
</div>
<div className="flex items-center justify-between py-3 border-b border-border">
<span className="text-foreground-muted">Email (SMTP)</span>
<span className="flex items-center gap-2">
{systemHealth?.email_configured ? (
<><CheckCircle className="w-4 h-4 text-accent" /><span className="text-accent">Configured</span></>
) : (
<><XCircle className="w-4 h-4 text-warning" /><span className="text-warning">Not configured</span></>
)}
</span>
</div>
<div className="flex items-center justify-between py-3 border-b border-border">
<span className="text-foreground-muted">Stripe Payments</span>
<span className="flex items-center gap-2">
{systemHealth?.stripe_configured ? (
<><CheckCircle className="w-4 h-4 text-accent" /><span className="text-accent">Configured</span></>
) : (
<><XCircle className="w-4 h-4 text-warning" /><span className="text-warning">Not configured</span></>
)}
</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-foreground-muted">Scheduler</span>
<span className="flex items-center gap-2">
{schedulerStatus?.scheduler_running ? (
<><CheckCircle className="w-4 h-4 text-accent" /><span className="text-accent">Running</span></>
) : (
<><XCircle className="w-4 h-4 text-danger" /><span className="text-danger">Stopped</span></>
)}
</span>
</div>
</div>
</div>
{/* Scheduler Jobs */}
{schedulerStatus && (
<div className="p-6 bg-background-secondary/50 border border-border rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">Scheduled Jobs</h3>
<div className="space-y-3">
{schedulerStatus.jobs.map((job) => (
<div key={job.id} className="flex items-center justify-between py-2">
<div>
<p className="text-body-sm font-medium text-foreground">{job.name}</p>
<p className="text-ui-xs text-foreground-subtle">{job.trigger}</p>
</div>
<div className="text-right">
<p className="text-ui-sm text-foreground-muted">Next run:</p>
<p className="text-ui-xs text-foreground">
{job.next_run ? new Date(job.next_run).toLocaleString() : 'Not scheduled'}
</p>
</div>
</div>
))}
</div>
<div className="mt-4 pt-4 border-t border-border">
<h4 className="text-sm font-medium text-foreground-muted mb-3">Last Runs</h4>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-ui-xs text-foreground-subtle">TLD Scrape</p>
<p className="text-ui-sm text-foreground">
{schedulerStatus.last_runs.tld_scrape
? new Date(schedulerStatus.last_runs.tld_scrape).toLocaleString()
: 'Never'}
</p>
</div>
<div>
<p className="text-ui-xs text-foreground-subtle">Auction Scrape</p>
<p className="text-ui-sm text-foreground">
{schedulerStatus.last_runs.auction_scrape
? new Date(schedulerStatus.last_runs.auction_scrape).toLocaleString()
: 'Never'}
</p>
</div>
<div>
<p className="text-ui-xs text-foreground-subtle">Domain Check</p>
<p className="text-ui-sm text-foreground">
{schedulerStatus.last_runs.domain_check
? new Date(schedulerStatus.last_runs.domain_check).toLocaleString()
: 'Never'}
</p>
</div>
</div>
</div>
</div>
)}
{/* Quick Actions */}
<div className="grid md:grid-cols-2 gap-6">
<div className="p-6 bg-background-secondary/50 border border-border rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">Manual Triggers</h3>
<div className="space-y-3">
<button
onClick={handleTriggerDomainChecks}
disabled={domainChecking}
className="w-full flex items-center justify-center gap-2 px-5 py-3 bg-foreground text-background rounded-xl font-medium hover:bg-foreground/90 disabled:opacity-50 transition-all"
>
{domainChecking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{domainChecking ? 'Checking...' : 'Check All Domains'}
</button>
<button
onClick={handleSendTestEmail}
disabled={sendingEmail || !systemHealth?.email_configured}
className="w-full flex items-center justify-center gap-2 px-5 py-3 bg-foreground/10 border border-border text-foreground rounded-xl font-medium hover:bg-foreground/20 disabled:opacity-50 transition-all"
>
{sendingEmail ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
{sendingEmail ? 'Sending...' : 'Send Test Email'}
</button>
</div>
</div>
<div className="p-6 bg-background-secondary/50 border border-border rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">Environment</h3>
<div className="font-mono text-sm text-foreground-muted space-y-2">
<div className="flex justify-between">
<span>SMTP_HOST</span>
<span className="text-foreground">smtp.zoho.eu</span>
</div>
<div className="flex justify-between">
<span>SMTP_PORT</span>
<span className="text-foreground">465</span>
</div>
<div className="flex justify-between">
<span>DATABASE</span>
<span className="text-accent">SQLite</span>
</div>
{systemHealth?.timestamp && (
<div className="pt-3 mt-3 border-t border-border text-xs">
Last check: {new Date(systemHealth.timestamp).toLocaleString()}
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Blog Tab */}
{activeTab === 'blog' && (
<div className="space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<p className="text-body-sm text-foreground-muted">{blogPostsTotal} blog posts</p>
<button
onClick={() => {
setEditingPost(null)
setBlogForm({ title: '', content: '', excerpt: '', category: '', tags: '', cover_image: '', is_published: false })
setShowBlogEditor(true)
}}
className="flex items-center gap-2 px-4 py-2 bg-accent text-background rounded-xl text-ui-sm font-medium hover:bg-accent-hover transition-all"
>
<Plus className="w-4 h-4" />
New Post
</button>
</div>
{blogPosts.length === 0 ? (
<div className="p-12 bg-background-secondary/50 border border-border rounded-xl text-center">
<BookOpen className="w-12 h-12 text-foreground-subtle mx-auto mb-4" />
<p className="text-foreground-muted">No blog posts yet</p>
<p className="text-ui-sm text-foreground-subtle mt-2">Create your first post to get started</p>
</div>
) : (
<div className="border border-border rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-background-secondary/50 border-b border-border">
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Title</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted hidden md:table-cell">Category</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Status</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted hidden lg:table-cell">Views</th>
<th className="text-right px-4 py-3 text-ui-sm font-medium text-foreground-muted">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{blogPosts.map((post) => (
<tr key={post.id} className="hover:bg-background-secondary/30">
<td className="px-4 py-3">
<p className="text-body-sm font-medium text-foreground line-clamp-1">{post.title}</p>
<p className="text-ui-xs text-foreground-subtle">{post.slug}</p>
</td>
<td className="px-4 py-3 hidden md:table-cell">
{post.category ? (
<span className="px-2 py-0.5 bg-background-tertiary text-foreground-muted text-ui-xs rounded-full">
{post.category}
</span>
) : (
<span className="text-foreground-subtle"></span>
)}
</td>
<td className="px-4 py-3">
<span className={clsx(
"px-2 py-0.5 text-ui-xs rounded-full",
post.is_published ? "bg-accent-muted text-accent" : "bg-warning/20 text-warning"
)}>
{post.is_published ? 'Published' : 'Draft'}
</span>
</td>
<td className="px-4 py-3 hidden lg:table-cell text-body-sm text-foreground-muted">
{post.view_count}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<a
href={`/blog/${post.slug}`}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-lg bg-background-tertiary text-foreground-subtle hover:text-foreground transition-colors"
title="View post"
>
<ExternalLink className="w-4 h-4" />
</a>
<button
onClick={() => {
setEditingPost(post)
setBlogForm({
title: post.title,
content: '', // Will be loaded when editing
excerpt: post.excerpt || '',
category: post.category || '',
tags: post.tags.join(', '),
cover_image: post.cover_image || '',
is_published: post.is_published,
})
setShowBlogEditor(true)
}}
className="p-1.5 rounded-lg bg-background-tertiary text-foreground-subtle hover:text-foreground transition-colors"
title="Edit post"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={async () => {
if (confirm('Are you sure you want to delete this post?')) {
try {
await api.deleteBlogPost(post.id)
setSuccess('Post deleted')
loadAdminData()
} catch {
setError('Failed to delete post')
}
}
}}
className="p-1.5 rounded-lg bg-danger/10 text-danger hover:bg-danger/20 transition-colors"
title="Delete post"
>
<TrashIcon className="w-4 h-4" />
</button>
{!post.is_published ? (
<button
onClick={async () => {
try {
await api.publishBlogPost(post.id)
setSuccess('Post published')
loadAdminData()
} catch {
setError('Failed to publish post')
}
}}
className="px-3 py-1 bg-accent text-background rounded-lg text-ui-xs font-medium hover:bg-accent-hover transition-all"
>
Publish
</button>
) : (
<button
onClick={async () => {
try {
await api.unpublishBlogPost(post.id)
setSuccess('Post unpublished')
loadAdminData()
} catch {
setError('Failed to unpublish post')
}
}}
className="px-3 py-1 bg-background-tertiary text-foreground-muted rounded-lg text-ui-xs font-medium hover:bg-foreground/10 transition-all"
>
Unpublish
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Activity Log Tab */}
{activeTab === 'activity' && (
<div className="space-y-6 animate-fade-in">
<p className="text-body-sm text-foreground-muted">{activityLogTotal} log entries</p>
{activityLog.length === 0 ? (
<div className="p-12 bg-background-secondary/50 border border-border rounded-xl text-center">
<History className="w-12 h-12 text-foreground-subtle mx-auto mb-4" />
<p className="text-foreground-muted">No activity logged yet</p>
</div>
) : (
<div className="border border-border rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-background-secondary/50 border-b border-border">
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Action</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Details</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Admin</th>
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Time</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{activityLog.map((log) => (
<tr key={log.id} className="hover:bg-background-secondary/30">
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-background-tertiary text-foreground text-ui-xs rounded-full font-medium">
{log.action}
</span>
</td>
<td className="px-4 py-3 text-body-sm text-foreground-muted">{log.details}</td>
<td className="px-4 py-3 text-body-sm text-foreground">{log.admin.email}</td>
<td className="px-4 py-3 text-body-sm text-foreground-muted">
{new Date(log.created_at).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</>
)}
</div>
</main>
{/* User Detail Modal */}
{selectedUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-background border border-border rounded-2xl shadow-2xl max-w-lg w-full max-h-[80vh] overflow-y-auto">
<div className="p-6 border-b border-border flex items-center justify-between">
<h2 className="text-xl font-display text-foreground">User Details</h2>
<button onClick={() => setSelectedUser(null)} className="text-foreground-muted hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<p className="text-ui-xs text-foreground-subtle uppercase mb-1">Email</p>
<p className="text-foreground font-medium">{selectedUser.email}</p>
</div>
<div>
<p className="text-ui-xs text-foreground-subtle uppercase mb-1">Name</p>
<p className="text-foreground">{selectedUser.name || 'Not set'}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-ui-xs text-foreground-subtle uppercase mb-1">Status</p>
<div className="flex flex-wrap gap-2">
{selectedUser.is_admin && <span className="px-2 py-0.5 bg-accent/20 text-accent text-ui-xs rounded-full">Admin</span>}
{selectedUser.is_verified && <span className="px-2 py-0.5 bg-accent-muted text-accent text-ui-xs rounded-full">Verified</span>}
{selectedUser.is_active ? (
<span className="px-2 py-0.5 bg-accent-muted text-accent text-ui-xs rounded-full">Active</span>
) : (
<span className="px-2 py-0.5 bg-danger-muted text-danger text-ui-xs rounded-full">Inactive</span>
)}
</div>
</div>
<div>
<p className="text-ui-xs text-foreground-subtle uppercase mb-1">Subscription</p>
<span className={clsx(
"px-2 py-1 text-ui-xs font-medium rounded-lg",
selectedUser.subscription.tier === 'tycoon' ? "bg-warning/20 text-warning" :
selectedUser.subscription.tier === 'trader' ? "bg-accent/20 text-accent" :
"bg-background-tertiary text-foreground-muted"
)}>
{selectedUser.subscription.tier_name}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-ui-xs text-foreground-subtle uppercase mb-1">Domains</p>
<p className="text-foreground">{selectedUser.domain_count} / {selectedUser.subscription.domain_limit}</p>
</div>
<div>
<p className="text-ui-xs text-foreground-subtle uppercase mb-1">Created</p>
<p className="text-foreground">{new Date(selectedUser.created_at).toLocaleDateString()}</p>
</div>
</div>
{selectedUser.last_login && (
<div>
<p className="text-ui-xs text-foreground-subtle uppercase mb-1">Last Login</p>
<p className="text-foreground">{new Date(selectedUser.last_login).toLocaleString()}</p>
</div>
)}
</div>
<div className="p-6 border-t border-border flex justify-between gap-3">
<button
onClick={() => handleDeleteUser(selectedUser.id, selectedUser.email)}
disabled={selectedUser.is_admin}
className="px-4 py-2 bg-danger/10 text-danger rounded-lg font-medium hover:bg-danger/20 disabled:opacity-30 disabled:cursor-not-allowed transition-all flex items-center gap-2"
title={selectedUser.is_admin ? 'Cannot delete admin users' : 'Delete user and all data'}
>
<Trash2 className="w-4 h-4" />
Delete User
</button>
<div className="flex gap-3">
<button
onClick={() => setSelectedUser(null)}
className="px-4 py-2 text-foreground-muted hover:text-foreground transition-colors"
>
Close
</button>
<button
onClick={() => {
handleToggleAdmin(selectedUser.id, selectedUser.is_admin)
setSelectedUser(null)
}}
className="px-4 py-2 bg-accent text-background rounded-lg font-medium hover:bg-accent-hover transition-all"
>
{selectedUser.is_admin ? 'Remove Admin' : 'Make Admin'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Blog Editor Modal */}
{showBlogEditor && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-background border border-border rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-border flex items-center justify-between">
<h2 className="text-xl font-display text-foreground">
{editingPost ? 'Edit Post' : 'New Blog Post'}
</h2>
<button onClick={() => setShowBlogEditor(false)} className="text-foreground-muted hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-ui-sm text-foreground-muted mb-2">Title *</label>
<input
type="text"
value={blogForm.title}
onChange={(e) => setBlogForm({ ...blogForm, title: e.target.value })}
placeholder="Enter post title..."
className="w-full px-4 py-3 bg-background-secondary border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50"
/>
</div>
<div>
<label className="block text-ui-sm text-foreground-muted mb-2">Excerpt</label>
<textarea
value={blogForm.excerpt}
onChange={(e) => setBlogForm({ ...blogForm, excerpt: e.target.value })}
placeholder="Brief summary for listings..."
rows={2}
className="w-full px-4 py-3 bg-background-secondary border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 resize-none"
/>
</div>
<div>
<label className="block text-ui-sm text-foreground-muted mb-2">Content * (HTML/Markdown)</label>
<textarea
value={blogForm.content}
onChange={(e) => setBlogForm({ ...blogForm, content: e.target.value })}
placeholder="<p>Write your blog post content here...</p>"
rows={12}
className="w-full px-4 py-3 bg-background-secondary border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 resize-none font-mono text-sm"
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-ui-sm text-foreground-muted mb-2">Category</label>
<input
type="text"
value={blogForm.category}
onChange={(e) => setBlogForm({ ...blogForm, category: e.target.value })}
placeholder="e.g., Domain Tips"
className="w-full px-4 py-3 bg-background-secondary border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50"
/>
</div>
<div>
<label className="block text-ui-sm text-foreground-muted mb-2">Tags (comma separated)</label>
<input
type="text"
value={blogForm.tags}
onChange={(e) => setBlogForm({ ...blogForm, tags: e.target.value })}
placeholder="domains, tips, strategy"
className="w-full px-4 py-3 bg-background-secondary border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50"
/>
</div>
</div>
<div>
<label className="block text-ui-sm text-foreground-muted mb-2">Cover Image URL</label>
<input
type="text"
value={blogForm.cover_image}
onChange={(e) => setBlogForm({ ...blogForm, cover_image: e.target.value })}
placeholder="https://example.com/image.jpg"
className="w-full px-4 py-3 bg-background-secondary border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50"
/>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="is_published"
checked={blogForm.is_published}
onChange={(e) => setBlogForm({ ...blogForm, is_published: e.target.checked })}
className="w-5 h-5 rounded border-border"
/>
<label htmlFor="is_published" className="text-body-sm text-foreground">
Publish immediately
</label>
</div>
</div>
<div className="p-6 border-t border-border flex justify-end gap-3">
<button
onClick={() => setShowBlogEditor(false)}
className="px-4 py-2 text-foreground-muted hover:text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={async () => {
if (!blogForm.title || !blogForm.content) {
setError('Title and content are required')
return
}
try {
const postData = {
title: blogForm.title,
content: blogForm.content,
excerpt: blogForm.excerpt || undefined,
category: blogForm.category || undefined,
tags: blogForm.tags ? blogForm.tags.split(',').map(t => t.trim()).filter(Boolean) : undefined,
cover_image: blogForm.cover_image || undefined,
is_published: blogForm.is_published,
}
if (editingPost) {
await api.updateBlogPost(editingPost.id, postData)
setSuccess('Post updated')
} else {
await api.createBlogPost(postData)
setSuccess('Post created')
}
setShowBlogEditor(false)
loadAdminData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save post')
}
}}
className="px-6 py-2 bg-accent text-background rounded-lg font-medium hover:bg-accent-hover transition-all"
>
{editingPost ? 'Save Changes' : 'Create Post'}
</button>
</div>
</div>
</div>
)}
<Footer />
</div>
)
}
// Stat Card Component
function StatCard({
title,
value,
subtitle,
icon: Icon
}: {
title: string
value: number
subtitle: string
icon: React.ComponentType<{ className?: string }>
}) {
return (
<div className="group relative p-5 sm:p-6 bg-background-secondary/50 border border-border rounded-2xl hover:border-accent/30 hover:bg-background-secondary transition-all duration-300">
<div className="absolute inset-0 rounded-2xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative">
<div className="w-10 h-10 bg-foreground/5 border border-border rounded-xl flex items-center justify-center mb-4 group-hover:border-accent/30 group-hover:bg-accent/5 transition-all">
<Icon className="w-5 h-5 text-foreground-muted group-hover:text-accent transition-colors" />
</div>
<p className="text-xs text-foreground-subtle uppercase tracking-wider mb-1">{title}</p>
<p className="text-3xl sm:text-4xl font-display text-foreground mb-1">{value.toLocaleString()}</p>
<p className="text-sm text-foreground-subtle">{subtitle}</p>
</div>
</div>
)
}