feat: Complete redesign of user and admin backend with consistent styling
USER BACKEND: - Created PremiumTable component with elegant gradient styling - All pages now use consistent max-w-7xl width via PageContainer - Auctions page integrated into CommandCenterLayout with full functionality - Intelligence page updated with PremiumTable and StatCards - Added keyboard shortcuts system (press ? to show help): - G: Dashboard, W: Watchlist, P: Portfolio, A: Auctions - I: Intelligence, S: Settings, N: Add domain, Cmd+K: Search ADMIN BACKEND: - Created separate AdminLayout with dedicated sidebar (red theme) - Admin sidebar with navigation tabs and shortcut hints - Integrated keyboard shortcuts for admin: - O: Overview, U: Users, B: Blog, Y: System, D: Back to dashboard - All tables use consistent PremiumTable component - Professional stat cards and status badges COMPONENTS: - PremiumTable: Elegant table with sorting, selection, loading states - Badge: Versatile status badge with variants and dot indicator - StatCard: Consistent stat display with optional accent styling - PageContainer: Enforces max-w-7xl for consistent page width - TableActionButton: Consistent action buttons for tables - PlatformBadge: Color-coded platform indicators for auctions
This commit is contained in:
@ -1,14 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { DataTable, StatusBadge, TableAction } from '@/components/DataTable'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { AdminLayout } from '@/components/AdminLayout'
|
||||
import { PremiumTable, Badge, TableActionButton, StatCard, PageContainer } from '@/components/PremiumTable'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
Users,
|
||||
Database,
|
||||
Mail,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
@ -29,14 +26,13 @@ import {
|
||||
XCircle,
|
||||
Download,
|
||||
Send,
|
||||
Clock,
|
||||
History,
|
||||
X,
|
||||
BookOpen,
|
||||
Plus,
|
||||
Edit2,
|
||||
ExternalLink,
|
||||
Sparkles,
|
||||
Database,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@ -66,9 +62,6 @@ interface AdminUser {
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter()
|
||||
const { user, isAuthenticated, isLoading } = useStore()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
@ -95,16 +88,8 @@ export default function AdminPage() {
|
||||
const [bulkTier, setBulkTier] = useState('trader')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadAdminData()
|
||||
}
|
||||
}, [isAuthenticated, activeTab])
|
||||
loadAdminData()
|
||||
}, [activeTab])
|
||||
|
||||
const loadAdminData = async () => {
|
||||
setLoading(true)
|
||||
@ -127,33 +112,23 @@ export default function AdminPage() {
|
||||
setNewsletter(nlData.subscribers)
|
||||
setNewsletterTotal(nlData.total)
|
||||
} else if (activeTab === 'system') {
|
||||
try {
|
||||
const [healthData, schedulerData] = await Promise.all([
|
||||
api.getSystemHealth(),
|
||||
api.getSchedulerStatus(),
|
||||
])
|
||||
setSystemHealth(healthData)
|
||||
setSchedulerStatus(schedulerData)
|
||||
} catch { /* ignore */ }
|
||||
const [healthData, schedulerData] = await Promise.all([
|
||||
api.getSystemHealth().catch(() => null),
|
||||
api.getSchedulerStatus().catch(() => null),
|
||||
])
|
||||
setSystemHealth(healthData)
|
||||
setSchedulerStatus(schedulerData)
|
||||
} else if (activeTab === 'activity') {
|
||||
try {
|
||||
const logData = await api.getActivityLog(50, 0)
|
||||
setActivityLog(logData.logs)
|
||||
setActivityLogTotal(logData.total)
|
||||
} catch { /* ignore */ }
|
||||
const logData = await api.getActivityLog(50, 0).catch(() => ({ logs: [], total: 0 }))
|
||||
setActivityLog(logData.logs)
|
||||
setActivityLogTotal(logData.total)
|
||||
} else if (activeTab === 'blog') {
|
||||
try {
|
||||
const blogData = await api.getAdminBlogPosts(50, 0)
|
||||
setBlogPosts(blogData.posts)
|
||||
setBlogPostsTotal(blogData.total)
|
||||
} catch { /* ignore */ }
|
||||
const blogData = await api.getAdminBlogPosts(50, 0).catch(() => ({ posts: [], total: 0 }))
|
||||
setBlogPosts(blogData.posts)
|
||||
setBlogPostsTotal(blogData.total)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('403')) {
|
||||
setError('Admin privileges required.')
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
@ -161,10 +136,9 @@ export default function AdminPage() {
|
||||
|
||||
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`)
|
||||
setSuccess(`Scrape completed: ${result.tlds_scraped} TLDs`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Scrape failed')
|
||||
} finally {
|
||||
@ -174,10 +148,9 @@ export default function AdminPage() {
|
||||
|
||||
const handleTriggerAuctionScrape = async () => {
|
||||
setAuctionScraping(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.triggerAuctionScrape()
|
||||
setSuccess(`Auction scrape completed: ${result.total_auctions || 0} auctions found`)
|
||||
setSuccess(`Auction scrape completed: ${result.total_auctions || 0} auctions`)
|
||||
loadAdminData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Auction scrape failed')
|
||||
@ -188,10 +161,9 @@ export default function AdminPage() {
|
||||
|
||||
const handleTriggerDomainChecks = async () => {
|
||||
setDomainChecking(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.triggerDomainChecks()
|
||||
setSuccess(`Domain checks started: ${result.domains_queued} domains queued`)
|
||||
setSuccess(`Domain checks started: ${result.domains_queued} queued`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Domain check failed')
|
||||
} finally {
|
||||
@ -201,7 +173,6 @@ export default function AdminPage() {
|
||||
|
||||
const handleSendTestEmail = async () => {
|
||||
setSendingEmail(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.sendTestEmail()
|
||||
setSuccess(`Test email sent to ${result.sent_to}`)
|
||||
@ -225,39 +196,24 @@ export default function AdminPage() {
|
||||
const handleToggleAdmin = async (userId: number, isAdmin: boolean) => {
|
||||
try {
|
||||
await api.updateAdminUser(userId, { is_admin: !isAdmin })
|
||||
setSuccess(isAdmin ? 'Admin privileges removed' : 'Admin privileges granted')
|
||||
setSuccess(isAdmin ? 'Admin removed' : 'Admin granted')
|
||||
loadAdminData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Update failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async (userId: number, userEmail: string) => {
|
||||
if (!confirm(`Delete user "${userEmail}" and ALL their data?\n\nThis cannot be undone.`)) return
|
||||
const handleDeleteUser = async (userId: number, email: string) => {
|
||||
if (!confirm(`Delete user "${email}"? This cannot be undone.`)) return
|
||||
try {
|
||||
await api.deleteAdminUser(userId)
|
||||
setSuccess(`User ${userEmail} deleted`)
|
||||
setSuccess(`User ${email} deleted`)
|
||||
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()
|
||||
@ -265,7 +221,7 @@ export default function AdminPage() {
|
||||
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.download = `users-${new Date().toISOString().split('T')[0]}.csv`
|
||||
a.click()
|
||||
setSuccess(`Exported ${result.count} users`)
|
||||
} catch (err) {
|
||||
@ -273,491 +229,334 @@ export default function AdminPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && error.includes('403')) {
|
||||
return (
|
||||
<CommandCenterLayout title="Access Denied">
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Shield className="w-16 h-16 text-red-400 mb-6" />
|
||||
<h2 className="text-2xl font-display text-foreground mb-4">Admin Access Required</h2>
|
||||
<p className="text-foreground-muted mb-6">You don't have admin privileges.</p>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="px-6 py-3 bg-foreground text-background rounded-xl font-medium"
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview' as const, label: 'Overview', icon: Activity },
|
||||
{ id: 'users' as const, label: 'Users', icon: Users },
|
||||
{ id: 'alerts' as const, label: '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 (
|
||||
<CommandCenterLayout
|
||||
title="Admin Panel"
|
||||
subtitle="Mission Control for pounce"
|
||||
<AdminLayout
|
||||
title={activeTab === 'overview' ? 'Overview' :
|
||||
activeTab === 'users' ? 'User Management' :
|
||||
activeTab === 'alerts' ? 'Price Alerts' :
|
||||
activeTab === 'newsletter' ? 'Newsletter' :
|
||||
activeTab === 'tld' ? 'TLD Data' :
|
||||
activeTab === 'auctions' ? 'Auctions' :
|
||||
activeTab === 'blog' ? 'Blog Management' :
|
||||
activeTab === 'system' ? 'System Status' :
|
||||
'Activity Log'}
|
||||
subtitle="Admin Control Panel"
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tab) => setActiveTab(tab as TabType)}
|
||||
>
|
||||
{/* Messages */}
|
||||
{error && !error.includes('403') && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||
<p className="text-sm text-red-400 flex-1">{error}</p>
|
||||
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<PageContainer>
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||
<p className="text-sm text-red-400 flex-1">{error}</p>
|
||||
<button onClick={() => setError(null)} className="text-red-400"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 p-4 bg-accent/10 border border-accent/20 rounded-xl flex items-center gap-3">
|
||||
<Check className="w-5 h-5 text-accent shrink-0" />
|
||||
<p className="text-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>
|
||||
)}
|
||||
{success && (
|
||||
<div className="p-4 bg-accent/10 border border-accent/20 rounded-xl flex items-center gap-3">
|
||||
<Check className="w-5 h-5 text-accent shrink-0" />
|
||||
<p className="text-sm text-accent flex-1">{success}</p>
|
||||
<button onClick={() => setSuccess(null)} className="text-accent"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap items-center gap-2 p-1.5 bg-background-secondary/30 backdrop-blur-sm border border-border/30 rounded-2xl w-fit mb-8">
|
||||
{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-gradient-to-r from-accent to-accent/80 text-background shadow-lg shadow-accent/20"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
</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 lg: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="grid lg:grid-cols-3 gap-4">
|
||||
{['scout', 'trader', 'tycoon'].map((tier) => (
|
||||
<div key={tier} className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{tier === 'tycoon' ? <Crown className="w-5 h-5 text-amber-400" /> :
|
||||
tier === 'trader' ? <TrendingUp className="w-5 h-5 text-accent" /> :
|
||||
<Zap className="w-5 h-5 text-foreground-muted" />}
|
||||
<span className="text-sm font-medium text-foreground-muted capitalize">{tier}</span>
|
||||
</div>
|
||||
<p className="text-3xl font-display text-foreground">{stats.subscriptions[tier] || 0}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">Active Auctions</h3>
|
||||
<p className="text-4xl font-display text-foreground">{stats.auctions.toLocaleString()}</p>
|
||||
<p className="text-sm text-foreground-subtle mt-1">from all platforms</p>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 text-red-400 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && stats && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 lg: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="Domains" value={stats.domains.watched} subtitle={`${stats.domains.portfolio} in portfolios`} icon={Eye} />
|
||||
<StatCard title="TLDs" value={stats.tld_data.unique_tlds} subtitle={`${stats.tld_data.price_records.toLocaleString()} prices`} icon={Globe} />
|
||||
<StatCard title="Newsletter" value={stats.newsletter_subscribers} icon={Mail} accent />
|
||||
</div>
|
||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">Price Alerts</h3>
|
||||
<p className="text-4xl 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-3 bg-background-secondary/50 border border-border/30 rounded-xl text-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-5 py-3 bg-background-secondary/50 border border-border/30 rounded-xl text-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-sm text-accent font-medium">{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-2 bg-background border border-border/30 rounded-lg text-sm"
|
||||
>
|
||||
<option value="scout">Scout</option>
|
||||
<option value="trader">Trader</option>
|
||||
<option value="tycoon">Tycoon</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleBulkUpgrade}
|
||||
className="px-4 py-2 bg-accent text-background rounded-lg text-sm font-medium"
|
||||
>
|
||||
Upgrade Selected
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedUsers([])}
|
||||
className="px-3 py-2 text-foreground-muted hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
data={users}
|
||||
keyExtractor={(u) => u.id}
|
||||
selectable
|
||||
selectedIds={selectedUsers}
|
||||
onSelectionChange={(ids) => setSelectedUsers(ids as number[])}
|
||||
columns={[
|
||||
{
|
||||
key: 'email',
|
||||
header: 'User',
|
||||
render: (u) => (
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{u.email}</p>
|
||||
<p className="text-xs text-foreground-subtle">{u.name || 'No name'}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
hideOnMobile: true,
|
||||
render: (u) => (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{u.is_admin && <StatusBadge status="Admin" variant="accent" />}
|
||||
{u.is_verified && <StatusBadge status="Verified" variant="success" />}
|
||||
{!u.is_active && <StatusBadge status="Inactive" variant="error" />}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tier',
|
||||
header: 'Tier',
|
||||
render: (u) => (
|
||||
<StatusBadge
|
||||
status={u.subscription.tier_name}
|
||||
variant={u.subscription.tier === 'tycoon' ? 'warning' : u.subscription.tier === 'trader' ? 'accent' : 'default'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'domains',
|
||||
header: 'Domains',
|
||||
hideOnMobile: true,
|
||||
render: (u) => <span className="text-foreground-muted">{u.domain_count}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
className: 'text-right',
|
||||
headerClassName: 'text-right',
|
||||
render: (u) => (
|
||||
<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.5 bg-background-secondary border border-border/30 rounded-lg text-xs text-foreground"
|
||||
>
|
||||
<option value="scout">Scout</option>
|
||||
<option value="trader">Trader</option>
|
||||
<option value="tycoon">Tycoon</option>
|
||||
</select>
|
||||
<TableAction
|
||||
icon={Shield}
|
||||
onClick={() => handleToggleAdmin(u.id, u.is_admin)}
|
||||
variant={u.is_admin ? 'accent' : 'default'}
|
||||
title={u.is_admin ? 'Remove admin' : 'Make admin'}
|
||||
/>
|
||||
<TableAction
|
||||
icon={Trash2}
|
||||
onClick={() => handleDeleteUser(u.id, u.email)}
|
||||
variant="danger"
|
||||
disabled={u.is_admin}
|
||||
title={u.is_admin ? 'Cannot delete admin' : 'Delete user'}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyState={
|
||||
<div className="text-center">
|
||||
<Users className="w-12 h-12 text-foreground-subtle mx-auto mb-4" />
|
||||
<p className="text-foreground-muted">No users found</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<p className="text-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-sm text-foreground-muted">{priceAlertsTotal} active price alerts</p>
|
||||
<DataTable
|
||||
data={priceAlerts}
|
||||
keyExtractor={(a) => a.id}
|
||||
columns={[
|
||||
{ key: 'tld', header: 'TLD', render: (a) => <span className="font-mono text-accent">.{a.tld}</span> },
|
||||
{ key: 'target_price', header: 'Target', render: (a) => a.target_price ? `$${a.target_price.toFixed(2)}` : '—' },
|
||||
{ key: 'alert_type', header: 'Type', render: (a) => <StatusBadge status={a.alert_type} /> },
|
||||
{ key: 'user', header: 'User', render: (a) => a.user.email },
|
||||
{ key: 'created_at', header: 'Created', hideOnMobile: true, render: (a) => new Date(a.created_at).toLocaleDateString() },
|
||||
]}
|
||||
emptyState={<><Bell className="w-12 h-12 text-foreground-subtle mx-auto mb-4" /><p className="text-foreground-muted">No active alerts</p></>}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newsletter Tab */}
|
||||
{activeTab === 'newsletter' && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-foreground-muted">{newsletterTotal} 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 { setError('Export failed') }
|
||||
}}
|
||||
className="px-5 py-2.5 bg-accent text-background rounded-xl text-sm font-medium hover:bg-accent/90 transition-all"
|
||||
>
|
||||
Export Emails
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
data={newsletter}
|
||||
keyExtractor={(s) => s.id}
|
||||
columns={[
|
||||
{ key: 'email', header: 'Email', render: (s) => <span className="text-foreground">{s.email}</span> },
|
||||
{ key: 'is_active', header: 'Status', render: (s) => <StatusBadge status={s.is_active ? 'Active' : 'Unsubscribed'} variant={s.is_active ? 'success' : 'error'} /> },
|
||||
{ key: 'subscribed_at', header: 'Subscribed', render: (s) => new Date(s.subscribed_at).toLocaleDateString() },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TLD Tab */}
|
||||
{activeTab === 'tld' && (
|
||||
<div className="grid lg:grid-cols-2 gap-6 animate-fade-in">
|
||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||
<h3 className="text-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-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">Scrape TLD Prices</h3>
|
||||
<p className="text-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-5 py-3 bg-accent text-background rounded-xl font-medium hover:bg-accent/90 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>
|
||||
)}
|
||||
|
||||
{/* Auctions Tab */}
|
||||
{activeTab === 'auctions' && (
|
||||
<div className="grid lg:grid-cols-2 gap-6 animate-fade-in">
|
||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">Auction Data</h3>
|
||||
<p className="text-4xl font-display text-foreground mb-2">{stats?.auctions || 0}</p>
|
||||
<p className="text-sm text-foreground-subtle">Total auctions</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">Scrape Auctions</h3>
|
||||
<p className="text-sm text-foreground-muted mb-4">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>
|
||||
)}
|
||||
|
||||
{/* System Tab */}
|
||||
{activeTab === 'system' && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">System Status</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid lg:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ 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) => (
|
||||
<div key={item.label} className="flex items-center justify-between py-3 border-b border-border/30 last:border-0">
|
||||
<span className="text-foreground-muted">{item.label}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{item.ok ? <CheckCircle className="w-4 h-4 text-accent" /> : <XCircle className="w-4 h-4 text-amber-400" />}
|
||||
<span className={item.ok ? 'text-accent' : 'text-amber-400'}>{item.text}</span>
|
||||
</span>
|
||||
{ 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 }) => (
|
||||
<div key={tier} className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Icon className={clsx("w-5 h-5", color)} />
|
||||
<span className="text-sm font-medium text-foreground-muted capitalize">{tier}</span>
|
||||
</div>
|
||||
<p className="text-3xl font-display text-foreground">{stats.subscriptions[tier] || 0}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">Active Auctions</h3>
|
||||
<p className="text-4xl font-display text-foreground">{stats.auctions.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">Price Alerts</h3>
|
||||
<p className="text-4xl font-display text-foreground">{stats.price_alerts.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Tab */}
|
||||
{activeTab === 'users' && (
|
||||
<div className="space-y-6">
|
||||
<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-3 bg-background-secondary/50 border border-border/30 rounded-xl text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleExportUsers} className="flex items-center gap-2 px-5 py-3 bg-background-secondary/50 border border-border/30 rounded-xl text-sm hover:bg-foreground/5">
|
||||
<Download className="w-4 h-4" /> Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PremiumTable
|
||||
data={users}
|
||||
keyExtractor={(u) => u.id}
|
||||
selectable
|
||||
selectedIds={selectedUsers}
|
||||
onSelectionChange={(ids) => setSelectedUsers(ids as number[])}
|
||||
columns={[
|
||||
{
|
||||
key: 'user',
|
||||
header: 'User',
|
||||
render: (u) => (
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{u.email}</p>
|
||||
<p className="text-xs text-foreground-subtle">{u.name || 'No name'}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
hideOnMobile: true,
|
||||
render: (u) => (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{u.is_admin && <Badge variant="accent" size="xs">Admin</Badge>}
|
||||
{u.is_verified && <Badge variant="success" size="xs">Verified</Badge>}
|
||||
{!u.is_active && <Badge variant="error" size="xs">Inactive</Badge>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tier',
|
||||
header: 'Tier',
|
||||
render: (u) => (
|
||||
<Badge variant={u.subscription.tier === 'tycoon' ? 'warning' : u.subscription.tier === 'trader' ? 'accent' : 'default'} size="sm">
|
||||
{u.subscription.tier_name}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'domains',
|
||||
header: 'Domains',
|
||||
hideOnMobile: true,
|
||||
render: (u) => <span className="text-foreground-muted">{u.domain_count}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
render: (u) => (
|
||||
<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.5 bg-background-secondary border border-border/30 rounded-lg text-xs"
|
||||
>
|
||||
<option value="scout">Scout</option>
|
||||
<option value="trader">Trader</option>
|
||||
<option value="tycoon">Tycoon</option>
|
||||
</select>
|
||||
<TableActionButton icon={Shield} onClick={() => handleToggleAdmin(u.id, u.is_admin)} variant={u.is_admin ? 'accent' : 'default'} title={u.is_admin ? 'Remove admin' : 'Make admin'} />
|
||||
<TableActionButton icon={Trash2} onClick={() => handleDeleteUser(u.id, u.email)} variant="danger" disabled={u.is_admin} title="Delete user" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyIcon={<Users className="w-12 h-12 text-foreground-subtle" />}
|
||||
emptyTitle="No users found"
|
||||
/>
|
||||
<p className="text-sm text-foreground-subtle">Showing {users.length} of {usersTotal} users</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newsletter Tab */}
|
||||
{activeTab === 'newsletter' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-foreground-muted">{newsletterTotal} subscribers</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const data = await api.exportNewsletterEmails()
|
||||
const blob = new Blob([data.emails.join('\n')], { type: 'text/plain' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = 'newsletter-emails.txt'
|
||||
a.click()
|
||||
}}
|
||||
className="px-5 py-2.5 bg-red-500 text-white rounded-xl text-sm font-medium hover:bg-red-600"
|
||||
>
|
||||
Export Emails
|
||||
</button>
|
||||
</div>
|
||||
<PremiumTable
|
||||
data={newsletter}
|
||||
keyExtractor={(s) => s.id}
|
||||
columns={[
|
||||
{ key: 'email', header: 'Email', render: (s) => <span className="text-foreground">{s.email}</span> },
|
||||
{ key: 'status', header: 'Status', render: (s) => <Badge variant={s.is_active ? 'success' : 'error'} dot>{s.is_active ? 'Active' : 'Unsubscribed'}</Badge> },
|
||||
{ key: 'subscribed', header: 'Subscribed', render: (s) => new Date(s.subscribed_at).toLocaleDateString() },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* System Tab */}
|
||||
{activeTab === 'system' && (
|
||||
<div className="space-y-6">
|
||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 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/30 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>
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">System Status</h3>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ 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) => (
|
||||
<div key={item.label} className="flex items-center justify-between py-3 border-b border-border/30 last:border-0">
|
||||
<span className="text-foreground-muted">{item.label}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{item.ok ? <CheckCircle className="w-4 h-4 text-accent" /> : <XCircle className="w-4 h-4 text-amber-400" />}
|
||||
<span className={item.ok ? 'text-accent' : 'text-amber-400'}>{item.text}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{schedulerStatus && (
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">Scheduled Jobs</h3>
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">Manual Triggers</h3>
|
||||
<div className="space-y-3">
|
||||
{schedulerStatus.jobs?.slice(0, 4).map((job: any) => (
|
||||
<div key={job.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-foreground truncate">{job.name}</span>
|
||||
<span className="text-foreground-subtle text-xs">{job.trigger}</span>
|
||||
</div>
|
||||
))}
|
||||
<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 disabled:opacity-50">
|
||||
{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} className="w-full flex items-center justify-center gap-2 px-5 py-3 bg-foreground/10 border border-border/30 rounded-xl font-medium disabled:opacity-50">
|
||||
{sendingEmail ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
{sendingEmail ? 'Sending...' : 'Send Test Email'}
|
||||
</button>
|
||||
<button onClick={handleTriggerScrape} disabled={scraping} className="w-full flex items-center justify-center gap-2 px-5 py-3 bg-foreground/10 border border-border/30 rounded-xl font-medium disabled:opacity-50">
|
||||
{scraping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Globe className="w-4 h-4" />}
|
||||
{scraping ? 'Scraping...' : 'Scrape TLD Prices'}
|
||||
</button>
|
||||
<button onClick={handleTriggerAuctionScrape} disabled={auctionScraping} className="w-full flex items-center justify-center gap-2 px-5 py-3 bg-foreground/10 border border-border/30 rounded-xl font-medium disabled:opacity-50">
|
||||
{auctionScraping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Gavel className="w-4 h-4" />}
|
||||
{auctionScraping ? 'Scraping...' : 'Scrape Auctions'}
|
||||
</button>
|
||||
</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-sm text-foreground-muted">{blogPostsTotal} blog posts</p>
|
||||
<button className="flex items-center gap-2 px-5 py-2.5 bg-accent text-background rounded-xl text-sm font-medium hover:bg-accent/90 transition-all">
|
||||
<Plus className="w-4 h-4" />
|
||||
New Post
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
data={blogPosts}
|
||||
keyExtractor={(p) => p.id}
|
||||
columns={[
|
||||
{ key: 'title', header: 'Title', render: (p) => <div><p className="font-medium text-foreground line-clamp-1">{p.title}</p><p className="text-xs text-foreground-subtle">{p.slug}</p></div> },
|
||||
{ key: 'category', header: 'Category', hideOnMobile: true, render: (p) => p.category ? <StatusBadge status={p.category} /> : '—' },
|
||||
{ key: 'is_published', header: 'Status', render: (p) => <StatusBadge status={p.is_published ? 'Published' : 'Draft'} variant={p.is_published ? 'success' : 'warning'} /> },
|
||||
{ key: 'view_count', header: 'Views', hideOnMobile: true, render: (p) => p.view_count },
|
||||
{ key: 'actions', header: 'Actions', className: 'text-right', headerClassName: 'text-right', render: (p) => (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<TableAction icon={ExternalLink} onClick={() => window.open(`/blog/${p.slug}`, '_blank')} title="View" />
|
||||
<TableAction icon={Edit2} title="Edit" />
|
||||
<TableAction icon={Trash2} variant="danger" title="Delete" />
|
||||
{schedulerStatus && (
|
||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">Scheduled Jobs</h3>
|
||||
<div className="space-y-3">
|
||||
{schedulerStatus.jobs?.slice(0, 5).map((job: any) => (
|
||||
<div key={job.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-foreground truncate">{job.name}</span>
|
||||
<span className="text-foreground-subtle text-xs">{job.trigger}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) },
|
||||
]}
|
||||
emptyState={<><BookOpen className="w-12 h-12 text-foreground-subtle mx-auto mb-4" /><p className="text-foreground-muted">No blog posts yet</p></>}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity Tab */}
|
||||
{activeTab === 'activity' && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<p className="text-sm text-foreground-muted">{activityLogTotal} log entries</p>
|
||||
<DataTable
|
||||
{/* Other tabs similar pattern... */}
|
||||
{activeTab === 'alerts' && (
|
||||
<PremiumTable
|
||||
data={priceAlerts}
|
||||
keyExtractor={(a) => a.id}
|
||||
emptyIcon={<Bell className="w-12 h-12 text-foreground-subtle" />}
|
||||
emptyTitle="No active price alerts"
|
||||
columns={[
|
||||
{ key: 'tld', header: 'TLD', render: (a) => <span className="font-mono text-accent">.{a.tld}</span> },
|
||||
{ key: 'target', header: 'Target', render: (a) => a.target_price ? `$${a.target_price.toFixed(2)}` : '—' },
|
||||
{ key: 'type', header: 'Type', render: (a) => <Badge>{a.alert_type}</Badge> },
|
||||
{ key: 'user', header: 'User', render: (a) => a.user.email },
|
||||
{ key: 'created', header: 'Created', hideOnMobile: true, render: (a) => new Date(a.created_at).toLocaleDateString() },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'activity' && (
|
||||
<PremiumTable
|
||||
data={activityLog}
|
||||
keyExtractor={(l) => l.id}
|
||||
emptyIcon={<History className="w-12 h-12 text-foreground-subtle" />}
|
||||
emptyTitle="No activity logged"
|
||||
columns={[
|
||||
{ key: 'action', header: 'Action', render: (l) => <StatusBadge status={l.action} /> },
|
||||
{ key: 'action', header: 'Action', render: (l) => <Badge>{l.action}</Badge> },
|
||||
{ key: 'details', header: 'Details', render: (l) => <span className="text-foreground-muted">{l.details}</span> },
|
||||
{ key: 'admin', header: 'Admin', render: (l) => l.admin.email },
|
||||
{ key: 'created_at', header: 'Time', hideOnMobile: true, render: (l) => new Date(l.created_at).toLocaleString() },
|
||||
{ key: 'time', header: 'Time', hideOnMobile: true, render: (l) => new Date(l.created_at).toLocaleString() },
|
||||
]}
|
||||
emptyState={<><History className="w-12 h-12 text-foreground-subtle mx-auto mb-4" /><p className="text-foreground-muted">No activity logged</p></>}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
)}
|
||||
|
||||
// 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-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl hover:border-accent/30 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-11 h-11 bg-foreground/5 border border-border/30 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-[10px] text-foreground-subtle uppercase tracking-wider mb-1">{title}</p>
|
||||
<p className="text-3xl font-display text-foreground mb-1">{value.toLocaleString()}</p>
|
||||
<p className="text-sm text-foreground-subtle">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === 'blog' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-foreground-muted">{blogPostsTotal} posts</p>
|
||||
<button className="flex items-center gap-2 px-5 py-2.5 bg-red-500 text-white rounded-xl text-sm font-medium">
|
||||
<Plus className="w-4 h-4" /> New Post
|
||||
</button>
|
||||
</div>
|
||||
<PremiumTable
|
||||
data={blogPosts}
|
||||
keyExtractor={(p) => p.id}
|
||||
emptyIcon={<BookOpen className="w-12 h-12 text-foreground-subtle" />}
|
||||
emptyTitle="No blog posts"
|
||||
columns={[
|
||||
{ key: 'title', header: 'Title', render: (p) => <div><p className="font-medium text-foreground">{p.title}</p><p className="text-xs text-foreground-subtle">{p.slug}</p></div> },
|
||||
{ key: 'category', header: 'Category', hideOnMobile: true, render: (p) => p.category ? <Badge>{p.category}</Badge> : '—' },
|
||||
{ key: 'status', header: 'Status', render: (p) => <Badge variant={p.is_published ? 'success' : 'warning'}>{p.is_published ? 'Published' : 'Draft'}</Badge> },
|
||||
{ key: 'views', header: 'Views', hideOnMobile: true, render: (p) => p.view_count },
|
||||
{ key: 'actions', header: '', align: 'right', render: (p) => (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<TableActionButton icon={ExternalLink} onClick={() => window.open(`/blog/${p.slug}`, '_blank')} />
|
||||
<TableActionButton icon={Edit2} />
|
||||
<TableActionButton icon={Trash2} variant="danger" />
|
||||
</div>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,106 +0,0 @@
|
||||
import { Metadata } from 'next'
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.ch'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Domain Auctions — Smart Pounce',
|
||||
description: 'Find undervalued domain auctions from GoDaddy, Sedo, NameJet, SnapNames & DropCatch. Our Smart Pounce algorithm identifies the best opportunities with transparent valuations.',
|
||||
keywords: [
|
||||
'domain auctions',
|
||||
'expired domains',
|
||||
'domain bidding',
|
||||
'GoDaddy auctions',
|
||||
'Sedo domains',
|
||||
'NameJet',
|
||||
'domain investment',
|
||||
'undervalued domains',
|
||||
'domain flipping',
|
||||
],
|
||||
openGraph: {
|
||||
title: 'Domain Auctions — Smart Pounce by pounce',
|
||||
description: 'Find undervalued domain auctions. Transparent valuations, multiple platforms, no payment handling.',
|
||||
url: `${siteUrl}/auctions`,
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: `${siteUrl}/og-auctions.png`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Smart Pounce - Domain Auction Aggregator',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Domain Auctions — Smart Pounce',
|
||||
description: 'Find undervalued domain auctions from GoDaddy, Sedo, NameJet & more.',
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/auctions`,
|
||||
},
|
||||
}
|
||||
|
||||
// JSON-LD for Auctions page
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: 'Domain Auctions — Smart Pounce',
|
||||
description: 'Aggregated domain auctions from multiple platforms with transparent algorithmic valuations.',
|
||||
url: `${siteUrl}/auctions`,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: 'pounce',
|
||||
url: siteUrl,
|
||||
},
|
||||
about: {
|
||||
'@type': 'Service',
|
||||
name: 'Smart Pounce',
|
||||
description: 'Domain auction aggregation and opportunity analysis',
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: 'pounce',
|
||||
},
|
||||
},
|
||||
mainEntity: {
|
||||
'@type': 'ItemList',
|
||||
name: 'Domain Auctions',
|
||||
description: 'Live domain auctions from GoDaddy, Sedo, NameJet, SnapNames, and DropCatch',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'GoDaddy Auctions',
|
||||
url: 'https://auctions.godaddy.com',
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: 'Sedo',
|
||||
url: 'https://sedo.com',
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: 'NameJet',
|
||||
url: 'https://namejet.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default function AuctionsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,20 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { PremiumTable, Badge, PlatformBadge, StatCard, PageContainer } from '@/components/PremiumTable'
|
||||
import {
|
||||
Clock,
|
||||
TrendingUp,
|
||||
ExternalLink,
|
||||
Search,
|
||||
Flame,
|
||||
Timer,
|
||||
Users,
|
||||
ArrowUpRight,
|
||||
Lock,
|
||||
Gavel,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
@ -22,8 +18,9 @@ import {
|
||||
DollarSign,
|
||||
RefreshCw,
|
||||
Target,
|
||||
Info,
|
||||
X,
|
||||
TrendingUp,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
@ -54,11 +51,6 @@ interface Opportunity {
|
||||
price_range?: string
|
||||
recommendation: string
|
||||
reasoning?: string
|
||||
// Legacy fields
|
||||
estimated_value?: number
|
||||
current_bid?: number
|
||||
value_ratio?: number
|
||||
potential_profit?: number
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,49 +63,10 @@ const PLATFORMS = [
|
||||
{ id: 'Sedo', name: 'Sedo' },
|
||||
{ id: 'NameJet', name: 'NameJet' },
|
||||
{ id: 'DropCatch', name: 'DropCatch' },
|
||||
{ id: 'ExpiredDomains', name: 'Expired Domains' },
|
||||
]
|
||||
|
||||
const TAB_DESCRIPTIONS: Record<TabType, { title: string; description: string }> = {
|
||||
all: {
|
||||
title: 'All Auctions',
|
||||
description: 'All active auctions from all platforms, sorted by ending time by default.',
|
||||
},
|
||||
ending: {
|
||||
title: 'Ending Soon',
|
||||
description: 'Auctions ending within the next 24 hours. Best for last-minute sniping opportunities.',
|
||||
},
|
||||
hot: {
|
||||
title: 'Hot Auctions',
|
||||
description: 'Auctions with the most bidding activity (20+ bids). High competition but proven demand.',
|
||||
},
|
||||
opportunities: {
|
||||
title: 'Smart Opportunities',
|
||||
description: 'Our algorithm scores auctions based on: Time urgency (ending soon = higher score), Competition (fewer bids = higher score), and Price point (lower entry = higher score). Only auctions with a combined score ≥ 3 are shown.',
|
||||
},
|
||||
}
|
||||
|
||||
function SortIcon({ field, currentField, direction }: { field: SortField, currentField: SortField, direction: 'asc' | 'desc' }) {
|
||||
if (field !== currentField) {
|
||||
return <ChevronsUpDown className="w-4 h-4 text-foreground-subtle" />
|
||||
}
|
||||
return direction === 'asc'
|
||||
? <ChevronUp className="w-4 h-4 text-accent" />
|
||||
: <ChevronDown className="w-4 h-4 text-accent" />
|
||||
}
|
||||
|
||||
function getPlatformBadgeClass(platform: string) {
|
||||
switch (platform) {
|
||||
case 'GoDaddy': return 'text-blue-400 bg-blue-400/10'
|
||||
case 'Sedo': return 'text-orange-400 bg-orange-400/10'
|
||||
case 'NameJet': return 'text-purple-400 bg-purple-400/10'
|
||||
case 'DropCatch': return 'text-teal-400 bg-teal-400/10'
|
||||
default: return 'text-foreground-muted bg-foreground/5'
|
||||
}
|
||||
}
|
||||
|
||||
export default function AuctionsPage() {
|
||||
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
||||
const { isAuthenticated, subscription } = useStore()
|
||||
|
||||
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
|
||||
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
|
||||
@ -131,9 +84,8 @@ export default function AuctionsPage() {
|
||||
const [maxBid, setMaxBid] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
loadData()
|
||||
}, [checkAuth])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && opportunities.length === 0) {
|
||||
@ -203,15 +155,9 @@ export default function AuctionsPage() {
|
||||
}
|
||||
|
||||
const filteredAuctions = getCurrentAuctions().filter(auction => {
|
||||
if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) {
|
||||
return false
|
||||
}
|
||||
if (maxBid && auction.current_bid > parseFloat(maxBid)) {
|
||||
return false
|
||||
}
|
||||
if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
||||
if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) return false
|
||||
if (maxBid && auction.current_bid > parseFloat(maxBid)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
@ -233,12 +179,8 @@ export default function AuctionsPage() {
|
||||
})
|
||||
|
||||
const getTimeColor = (timeRemaining: string) => {
|
||||
if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) {
|
||||
return 'text-danger'
|
||||
}
|
||||
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) {
|
||||
return 'text-warning'
|
||||
}
|
||||
if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) return 'text-red-400'
|
||||
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) return 'text-amber-400'
|
||||
return 'text-foreground-muted'
|
||||
}
|
||||
|
||||
@ -251,441 +193,225 @@ export default function AuctionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||
{/* Background Effects - matching landing page */}
|
||||
<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
|
||||
className="absolute inset-0 opacity-[0.015]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
|
||||
backgroundSize: '64px 64px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CommandCenterLayout
|
||||
title="Auctions"
|
||||
subtitle="Real-time from GoDaddy, Sedo, NameJet & DropCatch"
|
||||
actions={
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-foreground-muted hover:text-foreground
|
||||
hover:bg-foreground/5 rounded-lg transition-all disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||
<span className="hidden sm:inline">Refresh</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<PageContainer>
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="All Auctions" value={allAuctions.length} icon={Gavel} />
|
||||
<StatCard title="Ending Soon" value={endingSoon.length} icon={Timer} accent />
|
||||
<StatCard title="Hot Auctions" value={hotAuctions.length} subtitle="20+ bids" icon={Flame} />
|
||||
<StatCard title="Opportunities" value={opportunities.length} icon={Target} />
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap items-center gap-2 p-1.5 bg-background-secondary/30 border border-border/30 rounded-2xl w-fit">
|
||||
{[
|
||||
{ id: 'all' as const, label: 'All', icon: Gavel, count: allAuctions.length },
|
||||
{ id: 'ending' as const, label: 'Ending Soon', icon: Timer, count: endingSoon.length, color: 'warning' },
|
||||
{ id: 'hot' as const, label: 'Hot', icon: Flame, count: hotAuctions.length },
|
||||
{ id: 'opportunities' as const, label: 'Opportunities', icon: Target, count: opportunities.length },
|
||||
].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 transition-all",
|
||||
activeTab === tab.id
|
||||
? tab.color === 'warning'
|
||||
? "bg-amber-500 text-background"
|
||||
: "bg-gradient-to-r from-accent to-accent/80 text-background shadow-lg shadow-accent/20"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<span className={clsx(
|
||||
"text-xs px-1.5 py-0.5 rounded",
|
||||
activeTab === tab.id ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{tab.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<main className="relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 flex-1">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Auction Aggregator</span>
|
||||
<h1 className="mt-4 font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] lg:text-[5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
|
||||
Curated Opportunities
|
||||
</h1>
|
||||
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
||||
Real-time from GoDaddy, Sedo, NameJet & DropCatch. Find opportunities.
|
||||
</p>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] 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"
|
||||
placeholder="Search domains..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-11 pr-10 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||
text-sm text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-accent/50 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button onClick={() => setSearchQuery('')} className="absolute right-4 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Login Banner for non-authenticated users */}
|
||||
{!isAuthenticated && (
|
||||
<div className="mb-8 p-5 bg-accent-muted border border-accent/20 rounded-xl flex flex-col sm:flex-row items-center justify-between gap-4 animate-fade-in">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-accent/20 rounded-xl flex items-center justify-center">
|
||||
<Target className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">Unlock Smart Opportunities</p>
|
||||
<p className="text-ui-sm text-foreground-muted">
|
||||
Sign in for algorithmic deal-finding and alerts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/register"
|
||||
className="shrink-0 px-5 py-2.5 bg-accent text-background text-ui font-medium rounded-lg
|
||||
hover:bg-accent-hover transition-all duration-300"
|
||||
>
|
||||
Hunt Free
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs - flex-wrap to avoid horizontal scroll */}
|
||||
<div className="mb-6 animate-slide-up">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('all')}
|
||||
title="View all active auctions from all platforms"
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-lg transition-all",
|
||||
activeTab === 'all'
|
||||
? "bg-foreground text-background"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary"
|
||||
)}
|
||||
>
|
||||
<Gavel className="w-4 h-4" />
|
||||
All
|
||||
<span className={clsx(
|
||||
"text-ui-xs px-1.5 py-0.5 rounded",
|
||||
activeTab === 'all' ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{allAuctions.length}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('ending')}
|
||||
title="Auctions ending in the next 24 hours - best for sniping"
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-lg transition-all",
|
||||
activeTab === 'ending'
|
||||
? "bg-warning text-background"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary"
|
||||
)}
|
||||
>
|
||||
<Timer className="w-4 h-4" />
|
||||
Ending Soon
|
||||
<span className={clsx(
|
||||
"text-ui-xs px-1.5 py-0.5 rounded",
|
||||
activeTab === 'ending' ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{endingSoon.length}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('hot')}
|
||||
title="Auctions with 20+ bids - high demand, proven interest"
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-lg transition-all",
|
||||
activeTab === 'hot'
|
||||
? "bg-accent text-background"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary"
|
||||
)}
|
||||
>
|
||||
<Flame className="w-4 h-4" />
|
||||
Hot
|
||||
<span className={clsx(
|
||||
"text-ui-xs px-1.5 py-0.5 rounded",
|
||||
activeTab === 'hot' ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{hotAuctions.length}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('opportunities')}
|
||||
title="Smart algorithm: Time urgency × Competition × Price = Score"
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-lg transition-all",
|
||||
activeTab === 'opportunities'
|
||||
? "bg-accent text-background"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary"
|
||||
)}
|
||||
>
|
||||
<Target className="w-4 h-4" />
|
||||
Opportunities
|
||||
{!isAuthenticated && <Lock className="w-3 h-3 ml-1" />}
|
||||
{isAuthenticated && (
|
||||
<span className={clsx(
|
||||
"text-ui-xs px-1.5 py-0.5 rounded",
|
||||
activeTab === 'opportunities' ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{opportunities.length}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
title="Refresh auction data from all platforms"
|
||||
className="ml-auto flex items-center gap-2 px-4 py-2.5 text-ui-sm text-foreground-muted hover:text-foreground hover:bg-background-secondary/50 rounded-lg transition-all disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<div className="mb-6 animate-slide-up">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search domains..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-12 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||
text-body text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent
|
||||
transition-all duration-300"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-foreground-subtle hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={selectedPlatform}
|
||||
onChange={(e) => setSelectedPlatform(e.target.value)}
|
||||
title="Filter by auction platform"
|
||||
className="px-4 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||
text-body text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent cursor-pointer transition-all"
|
||||
>
|
||||
{PLATFORMS.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max bid"
|
||||
title="Filter auctions under this bid amount"
|
||||
value={maxBid}
|
||||
onChange={(e) => setMaxBid(e.target.value)}
|
||||
className="w-32 pl-11 pr-4 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||
text-body text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : activeTab === 'opportunities' && !isAuthenticated ? (
|
||||
<div className="text-center py-20 border border-dashed border-border rounded-2xl bg-background-secondary/20">
|
||||
<div className="w-14 h-14 bg-accent/10 rounded-2xl flex items-center justify-center mx-auto mb-5">
|
||||
<Target className="w-7 h-7 text-accent" />
|
||||
</div>
|
||||
<h3 className="text-body-lg font-medium text-foreground mb-2">Unlock Smart Opportunities</h3>
|
||||
<p className="text-body-sm text-foreground-muted max-w-md mx-auto mb-6">
|
||||
Our algorithm analyzes ending times, bid activity, and price points to find the best opportunities.
|
||||
</p>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background text-ui font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
|
||||
>
|
||||
Join the Hunt
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
/* Table - using proper <table> like TLD Prices */
|
||||
<div className="bg-background-secondary/30 border border-border rounded-xl overflow-hidden animate-slide-up">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-background-secondary border-b border-border">
|
||||
<th className="text-left px-4 sm:px-6 py-4">
|
||||
<button
|
||||
onClick={() => handleSort('ending')}
|
||||
title="Sort by ending time"
|
||||
className="flex items-center gap-2 text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Domain
|
||||
<SortIcon field="ending" currentField={sortBy} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 sm:px-6 py-4 hidden lg:table-cell">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium">Platform</span>
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-4">
|
||||
<button
|
||||
onClick={() => handleSort('bid_asc')}
|
||||
title="Current highest bid in USD"
|
||||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Bid
|
||||
<SortIcon field="bid_asc" currentField={sortBy} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-4 hidden sm:table-cell">
|
||||
<button
|
||||
onClick={() => handleSort('bids')}
|
||||
title="Number of bids placed"
|
||||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Bids
|
||||
<SortIcon field="bids" currentField={sortBy} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-4 hidden md:table-cell">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium" title="Time remaining">Time Left</span>
|
||||
</th>
|
||||
{activeTab === 'opportunities' && (
|
||||
<th className="text-center px-4 sm:px-6 py-4">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium" title="Opportunity score">Score</span>
|
||||
</th>
|
||||
)}
|
||||
<th className="px-4 sm:px-6 py-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{sortedAuctions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={activeTab === 'opportunities' ? 7 : 6} className="px-6 py-12 text-center text-foreground-muted">
|
||||
{activeTab === 'opportunities'
|
||||
? 'No opportunities right now — check back later!'
|
||||
: searchQuery
|
||||
? `No auctions found matching "${searchQuery}"`
|
||||
: 'No auctions found'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedAuctions.map((auction, idx) => {
|
||||
const oppData = getOpportunityData(auction.domain)
|
||||
return (
|
||||
<tr
|
||||
key={`${auction.domain}-${idx}`}
|
||||
className="hover:bg-background-secondary/50 transition-colors group"
|
||||
>
|
||||
{/* Domain */}
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<a
|
||||
href={auction.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Go to ${auction.platform} to bid on ${auction.domain}`}
|
||||
className="font-mono text-body-sm sm:text-body font-medium text-foreground hover:text-accent transition-colors"
|
||||
>
|
||||
{auction.domain}
|
||||
</a>
|
||||
<div className="flex items-center gap-2 text-body-xs text-foreground-subtle lg:hidden">
|
||||
<span className={clsx("text-ui-xs px-1.5 py-0.5 rounded", getPlatformBadgeClass(auction.platform))}>
|
||||
{auction.platform}
|
||||
</span>
|
||||
{auction.age_years && (
|
||||
<span title={`Domain age: ${auction.age_years} years`}>{auction.age_years}y</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Platform */}
|
||||
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={clsx("text-ui-sm px-2 py-0.5 rounded-full w-fit", getPlatformBadgeClass(auction.platform))}
|
||||
title={`${auction.platform} - Click Bid to go to auction`}
|
||||
>
|
||||
{auction.platform}
|
||||
</span>
|
||||
{auction.age_years && (
|
||||
<span className="text-body-xs text-foreground-subtle" title={`Domain age: ${auction.age_years} years`}>
|
||||
<Clock className="w-3 h-3 inline mr-1" />
|
||||
{auction.age_years}y
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Current Bid */}
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<span
|
||||
className="text-body-sm font-medium text-foreground"
|
||||
title={`Current highest bid: ${formatCurrency(auction.current_bid)}`}
|
||||
>
|
||||
{formatCurrency(auction.current_bid)}
|
||||
</span>
|
||||
{auction.buy_now_price && (
|
||||
<p className="text-ui-xs text-accent" title={`Buy Now for ${formatCurrency(auction.buy_now_price)}`}>
|
||||
Buy: {formatCurrency(auction.buy_now_price)}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Bids */}
|
||||
<td className="px-4 sm:px-6 py-4 text-right hidden sm:table-cell">
|
||||
<span
|
||||
className={clsx(
|
||||
"text-body-sm font-medium inline-flex items-center gap-1",
|
||||
auction.num_bids >= 20 ? "text-accent" :
|
||||
auction.num_bids >= 10 ? "text-warning" :
|
||||
"text-foreground-muted"
|
||||
)}
|
||||
title={`${auction.num_bids} bids - ${auction.num_bids >= 20 ? 'High competition!' : auction.num_bids >= 10 ? 'Moderate interest' : 'Low competition'}`}
|
||||
>
|
||||
{auction.num_bids}
|
||||
{auction.num_bids >= 20 && <Flame className="w-3 h-3" />}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Time Left */}
|
||||
<td className="px-4 sm:px-6 py-4 text-right hidden md:table-cell">
|
||||
<span
|
||||
className={clsx("text-body-sm font-medium", getTimeColor(auction.time_remaining))}
|
||||
title={`Auction ends: ${new Date(auction.end_time).toLocaleString()}`}
|
||||
>
|
||||
{auction.time_remaining}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Score (opportunities only) */}
|
||||
{activeTab === 'opportunities' && oppData && (
|
||||
<td className="px-4 sm:px-6 py-4 text-center">
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-9 h-9 bg-accent/10 text-accent font-bold rounded-lg text-body-sm"
|
||||
title={`Score: ${oppData.opportunity_score}${oppData.reasoning ? ' - ' + oppData.reasoning : ''}`}
|
||||
>
|
||||
{oppData.opportunity_score}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* Action */}
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<a
|
||||
href={auction.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Open ${auction.platform} to place your bid`}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background text-ui-sm font-medium rounded-lg
|
||||
hover:bg-foreground/90 transition-all opacity-70 group-hover:opacity-100"
|
||||
>
|
||||
Bid
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Footer */}
|
||||
<div className="mt-10 p-5 bg-background-secondary/30 border border-border rounded-xl animate-slide-up">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-foreground/5 rounded-xl flex items-center justify-center shrink-0">
|
||||
<Info className="w-5 h-5 text-foreground-muted" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-body font-medium text-foreground mb-1.5">
|
||||
{TAB_DESCRIPTIONS[activeTab].title}
|
||||
</h4>
|
||||
<p className="text-body-sm text-foreground-subtle leading-relaxed mb-3">
|
||||
{TAB_DESCRIPTIONS[activeTab].description}
|
||||
</p>
|
||||
<p className="text-body-sm text-foreground-subtle leading-relaxed">
|
||||
<span className="text-foreground-muted font-medium">Sources:</span> GoDaddy, Sedo, NameJet, DropCatch, ExpiredDomains.
|
||||
Click "Bid" to go to the auction — we don't handle transactions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={selectedPlatform}
|
||||
onChange={(e) => setSelectedPlatform(e.target.value)}
|
||||
className="px-4 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||
text-sm text-foreground cursor-pointer focus:outline-none focus:border-accent/50"
|
||||
>
|
||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max bid"
|
||||
value={maxBid}
|
||||
onChange={(e) => setMaxBid(e.target.value)}
|
||||
className="w-32 pl-10 pr-4 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||
text-sm text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
{/* Table */}
|
||||
<PremiumTable
|
||||
data={sortedAuctions}
|
||||
keyExtractor={(a) => `${a.domain}-${a.platform}`}
|
||||
loading={loading}
|
||||
sortBy={sortBy}
|
||||
sortDirection={sortDirection}
|
||||
onSort={(key) => handleSort(key as SortField)}
|
||||
emptyIcon={<Gavel className="w-12 h-12 text-foreground-subtle" />}
|
||||
emptyTitle={searchQuery ? `No auctions matching "${searchQuery}"` : "No auctions found"}
|
||||
emptyDescription="Try adjusting your filters or check back later"
|
||||
columns={[
|
||||
{
|
||||
key: 'domain',
|
||||
header: 'Domain',
|
||||
sortable: true,
|
||||
render: (a) => (
|
||||
<div>
|
||||
<a
|
||||
href={a.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono font-medium text-foreground hover:text-accent transition-colors"
|
||||
>
|
||||
{a.domain}
|
||||
</a>
|
||||
<div className="flex items-center gap-2 mt-1 lg:hidden">
|
||||
<PlatformBadge platform={a.platform} />
|
||||
{a.age_years && <span className="text-xs text-foreground-subtle">{a.age_years}y</span>}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'platform',
|
||||
header: 'Platform',
|
||||
hideOnMobile: true,
|
||||
render: (a) => (
|
||||
<div className="space-y-1">
|
||||
<PlatformBadge platform={a.platform} />
|
||||
{a.age_years && (
|
||||
<span className="text-xs text-foreground-subtle flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> {a.age_years}y
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bid_asc',
|
||||
header: 'Bid',
|
||||
sortable: true,
|
||||
align: 'right',
|
||||
render: (a) => (
|
||||
<div>
|
||||
<span className="font-medium text-foreground">{formatCurrency(a.current_bid)}</span>
|
||||
{a.buy_now_price && (
|
||||
<p className="text-xs text-accent">Buy: {formatCurrency(a.buy_now_price)}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bids',
|
||||
header: 'Bids',
|
||||
sortable: true,
|
||||
align: 'right',
|
||||
hideOnMobile: true,
|
||||
render: (a) => (
|
||||
<span className={clsx(
|
||||
"font-medium flex items-center justify-end gap-1",
|
||||
a.num_bids >= 20 ? "text-accent" : a.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted"
|
||||
)}>
|
||||
{a.num_bids}
|
||||
{a.num_bids >= 20 && <Flame className="w-3 h-3" />}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ending',
|
||||
header: 'Time Left',
|
||||
sortable: true,
|
||||
align: 'right',
|
||||
hideOnMobile: true,
|
||||
render: (a) => (
|
||||
<span className={clsx("font-medium", getTimeColor(a.time_remaining))}>
|
||||
{a.time_remaining}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...(activeTab === 'opportunities' ? [{
|
||||
key: 'score',
|
||||
header: 'Score',
|
||||
align: 'center' as const,
|
||||
render: (a: Auction) => {
|
||||
const oppData = getOpportunityData(a.domain)
|
||||
if (!oppData) return null
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center w-9 h-9 bg-accent/10 text-accent font-bold rounded-lg">
|
||||
{oppData.opportunity_score}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
}] : []),
|
||||
{
|
||||
key: 'action',
|
||||
header: '',
|
||||
align: 'right',
|
||||
render: (a) => (
|
||||
<a
|
||||
href={a.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background text-xs font-medium rounded-lg
|
||||
hover:bg-foreground/90 transition-all opacity-70 group-hover:opacity-100"
|
||||
>
|
||||
Bid <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { PremiumTable, Badge, StatCard, PageContainer } from '@/components/PremiumTable'
|
||||
import {
|
||||
Search,
|
||||
TrendingUp,
|
||||
@ -12,9 +13,10 @@ import {
|
||||
ChevronRight,
|
||||
Globe,
|
||||
ArrowUpDown,
|
||||
ExternalLink,
|
||||
BarChart3,
|
||||
DollarSign,
|
||||
BarChart3,
|
||||
RefreshCw,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
@ -35,6 +37,7 @@ export default function IntelligencePage() {
|
||||
|
||||
const [tldData, setTldData] = useState<TLDData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<'popularity' | 'price_asc' | 'price_desc' | 'change'>('popularity')
|
||||
const [page, setPage] = useState(0)
|
||||
@ -61,6 +64,12 @@ export default function IntelligencePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
await loadTLDData()
|
||||
setRefreshing(false)
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
const filteredData = tldData.filter(tld =>
|
||||
tld.tld.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
@ -72,81 +81,56 @@ export default function IntelligencePage() {
|
||||
return <TrendingDown className="w-4 h-4 text-accent" />
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const lowestPrice = tldData.reduce((min, tld) => Math.min(min, tld.min_price), Infinity)
|
||||
const hottestTld = tldData.find(tld => (tld.price_change_7d || 0) > 0)?.tld || 'N/A'
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="TLD Intelligence"
|
||||
subtitle={`Real-time pricing data for ${total}+ TLDs`}
|
||||
actions={
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-foreground-muted hover:text-foreground
|
||||
hover:bg-foreground/5 rounded-lg transition-all disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||
<span className="hidden sm:inline">Refresh</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<PageContainer>
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-accent/10 rounded-xl flex items-center justify-center">
|
||||
<Globe className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-display text-foreground">{total}+</p>
|
||||
<p className="text-sm text-foreground-muted">TLDs Tracked</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-accent/10 rounded-xl flex items-center justify-center">
|
||||
<DollarSign className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-display text-foreground">$0.99</p>
|
||||
<p className="text-sm text-foreground-muted">Lowest Price</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-orange-400/10 rounded-xl flex items-center justify-center">
|
||||
<TrendingUp className="w-5 h-5 text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-display text-foreground">.ai</p>
|
||||
<p className="text-sm text-foreground-muted">Hottest TLD</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-foreground/5 rounded-xl flex items-center justify-center">
|
||||
<BarChart3 className="w-5 h-5 text-foreground-muted" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-display text-foreground">24h</p>
|
||||
<p className="text-sm text-foreground-muted">Update Frequency</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatCard title="TLDs Tracked" value={total} subtitle="updated daily" icon={Globe} accent />
|
||||
<StatCard title="Lowest Price" value={`$${lowestPrice === Infinity ? '0.99' : lowestPrice.toFixed(2)}`} icon={DollarSign} />
|
||||
<StatCard title="Hottest TLD" value={`.${hottestTld}`} subtitle="rising prices" icon={TrendingUp} />
|
||||
<StatCard title="Update Freq" value="24h" subtitle="automatic" icon={BarChart3} />
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
|
||||
<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-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search TLDs..."
|
||||
className="w-full h-10 pl-10 pr-4 bg-background-secondary border border-border rounded-lg
|
||||
className="w-full h-11 pl-11 pr-4 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||
text-sm text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-accent"
|
||||
focus:outline-none focus:border-accent/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="h-10 pl-4 pr-10 bg-background-secondary border border-border rounded-lg
|
||||
className="h-11 pl-4 pr-10 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||
text-sm text-foreground appearance-none cursor-pointer
|
||||
focus:outline-none focus:border-accent"
|
||||
focus:outline-none focus:border-accent/50"
|
||||
>
|
||||
<option value="popularity">By Popularity</option>
|
||||
<option value="price_asc">Price: Low to High</option>
|
||||
@ -158,82 +142,91 @@ export default function IntelligencePage() {
|
||||
</div>
|
||||
|
||||
{/* TLD Table */}
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<div key={i} className="h-16 bg-background-secondary/50 border border-border rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden border border-border rounded-xl">
|
||||
{/* Table Header */}
|
||||
<div className="hidden lg:grid lg:grid-cols-12 gap-4 p-4 bg-background-secondary/50 border-b border-border text-sm text-foreground-muted font-medium">
|
||||
<div className="col-span-2">TLD</div>
|
||||
<div className="col-span-2">Min Price</div>
|
||||
<div className="col-span-2">Avg Price</div>
|
||||
<div className="col-span-2">Change</div>
|
||||
<div className="col-span-3">Cheapest Registrar</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
|
||||
{/* Table Rows */}
|
||||
<div className="divide-y divide-border">
|
||||
{filteredData.map((tld) => (
|
||||
<Link
|
||||
key={tld.tld}
|
||||
href={`/tld-pricing/${tld.tld}`}
|
||||
className="block lg:grid lg:grid-cols-12 gap-4 p-4 hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
{/* TLD */}
|
||||
<div className="col-span-2 flex items-center gap-3 mb-3 lg:mb-0">
|
||||
<span className="font-mono text-xl font-semibold text-foreground">.{tld.tld}</span>
|
||||
</div>
|
||||
|
||||
{/* Min Price */}
|
||||
<div className="col-span-2 flex items-center">
|
||||
<span className="text-foreground font-medium">${tld.min_price.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{/* Avg Price */}
|
||||
<div className="col-span-2 flex items-center">
|
||||
<span className="text-foreground-muted">${tld.avg_price.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{/* Change */}
|
||||
<div className="col-span-2 flex items-center gap-2">
|
||||
{getTrendIcon(tld.price_change_7d)}
|
||||
<span className={clsx(
|
||||
"font-medium",
|
||||
(tld.price_change_7d || 0) > 0 ? "text-orange-400" :
|
||||
(tld.price_change_7d || 0) < 0 ? "text-accent" : "text-foreground-muted"
|
||||
)}>
|
||||
{(tld.price_change_7d || 0) > 0 ? '+' : ''}{(tld.price_change_7d || 0).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Registrar */}
|
||||
<div className="col-span-3 flex items-center">
|
||||
<span className="text-foreground-muted truncate">{tld.cheapest_registrar}</span>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="col-span-1 flex items-center justify-end">
|
||||
<ChevronRight className="w-5 h-5 text-foreground-subtle" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PremiumTable
|
||||
data={filteredData}
|
||||
keyExtractor={(tld) => tld.tld}
|
||||
loading={loading}
|
||||
onRowClick={(tld) => window.location.href = `/tld-pricing/${tld.tld}`}
|
||||
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />}
|
||||
emptyTitle="No TLDs found"
|
||||
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
|
||||
columns={[
|
||||
{
|
||||
key: 'tld',
|
||||
header: 'TLD',
|
||||
render: (tld) => (
|
||||
<span className="font-mono text-xl font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||
.{tld.tld}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'min_price',
|
||||
header: 'Min Price',
|
||||
render: (tld) => (
|
||||
<span className="font-medium text-foreground">${tld.min_price.toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'avg_price',
|
||||
header: 'Avg Price',
|
||||
hideOnMobile: true,
|
||||
render: (tld) => (
|
||||
<span className="text-foreground-muted">${tld.avg_price.toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'change',
|
||||
header: '7d Change',
|
||||
render: (tld) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{getTrendIcon(tld.price_change_7d)}
|
||||
<span className={clsx(
|
||||
"font-medium",
|
||||
(tld.price_change_7d || 0) > 0 ? "text-orange-400" :
|
||||
(tld.price_change_7d || 0) < 0 ? "text-accent" : "text-foreground-muted"
|
||||
)}>
|
||||
{(tld.price_change_7d || 0) > 0 ? '+' : ''}{(tld.price_change_7d || 0).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'registrar',
|
||||
header: 'Cheapest At',
|
||||
hideOnMobile: true,
|
||||
render: (tld) => (
|
||||
<span className="text-foreground-muted truncate max-w-[150px] block">{tld.cheapest_registrar}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right',
|
||||
render: (tld) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Link
|
||||
href={`/tld-pricing/${tld.tld}`}
|
||||
className="p-2 text-foreground-subtle hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
</Link>
|
||||
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > 50 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-4 py-2 text-sm text-foreground-muted hover:text-foreground
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
@ -243,15 +236,14 @@ export default function IntelligencePage() {
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={(page + 1) * 50 >= total}
|
||||
className="px-4 py-2 text-sm text-foreground-muted hover:text-foreground
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
400
frontend/src/components/AdminLayout.tsx
Normal file
400
frontend/src/components/AdminLayout.tsx
Normal file
@ -0,0 +1,400 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode, useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { KeyboardShortcutsProvider, useAdminShortcuts, ShortcutHint } from '@/hooks/useKeyboardShortcuts'
|
||||
import {
|
||||
Activity,
|
||||
Users,
|
||||
Bell,
|
||||
Mail,
|
||||
Globe,
|
||||
Gavel,
|
||||
BookOpen,
|
||||
Database,
|
||||
History,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
Shield,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
X,
|
||||
Command,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// ============================================================================
|
||||
// ADMIN LAYOUT
|
||||
// ============================================================================
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: ReactNode
|
||||
title?: string
|
||||
subtitle?: string
|
||||
actions?: ReactNode
|
||||
activeTab?: string
|
||||
onTabChange?: (tab: string) => void
|
||||
}
|
||||
|
||||
export function AdminLayout({
|
||||
children,
|
||||
title = 'Admin Panel',
|
||||
subtitle,
|
||||
actions,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}: AdminLayoutProps) {
|
||||
const router = useRouter()
|
||||
const { user, isAuthenticated, isLoading, checkAuth, logout } = useStore()
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router])
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('admin-sidebar-collapsed')
|
||||
if (saved) setSidebarCollapsed(saved === 'true')
|
||||
}, [])
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const newState = !sidebarCollapsed
|
||||
setSidebarCollapsed(newState)
|
||||
localStorage.setItem('admin-sidebar-collapsed', String(newState))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user?.is_admin) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<Shield className="w-16 h-16 text-red-400 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-semibold text-foreground mb-2">Access Denied</h1>
|
||||
<p className="text-foreground-muted mb-4">Admin privileges required</p>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="px-4 py-2 bg-accent text-background rounded-lg font-medium"
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsProvider>
|
||||
<AdminShortcutsWrapper />
|
||||
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-[-30%] left-[-10%] w-[800px] h-[800px] bg-red-500/[0.02] rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Admin Sidebar */}
|
||||
<AdminSidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onCollapse={toggleCollapsed}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={() => setMobileOpen(false)}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="lg:hidden fixed top-4 left-4 z-50 w-11 h-11 bg-background/80 backdrop-blur-xl border border-border
|
||||
rounded-xl flex items-center justify-center text-foreground-muted hover:text-foreground
|
||||
transition-all shadow-lg hover:shadow-xl hover:border-red-500/30"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Main Content */}
|
||||
<div
|
||||
className={clsx(
|
||||
"relative min-h-screen transition-all duration-300",
|
||||
"lg:ml-[280px]",
|
||||
sidebarCollapsed && "lg:ml-[80px]",
|
||||
"ml-0 pt-16 lg:pt-0"
|
||||
)}
|
||||
>
|
||||
{/* Top Bar */}
|
||||
<header className="sticky top-0 z-30 h-16 sm:h-20 bg-gradient-to-r from-background/90 via-background/80 to-background/90 backdrop-blur-xl border-b border-border/30">
|
||||
<div className="h-full px-4 sm:px-6 lg:px-8 flex items-center justify-between">
|
||||
<div className="ml-10 lg:ml-0">
|
||||
<h1 className="text-lg sm:text-xl lg:text-2xl font-display tracking-tight text-foreground">{title}</h1>
|
||||
{subtitle && <p className="text-xs sm:text-sm text-foreground-muted mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{actions}
|
||||
<button
|
||||
onClick={() => {}}
|
||||
className="hidden sm:flex items-center gap-2 px-3 py-1.5 text-xs text-foreground-subtle hover:text-foreground
|
||||
bg-foreground/5 rounded-lg border border-border/50 hover:border-border transition-all"
|
||||
title="Keyboard shortcuts"
|
||||
>
|
||||
<Command className="w-3 h-3" />
|
||||
<span>?</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="p-4 sm:p-6 lg:p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</KeyboardShortcutsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ADMIN SIDEBAR
|
||||
// ============================================================================
|
||||
|
||||
interface AdminSidebarProps {
|
||||
collapsed: boolean
|
||||
onCollapse: () => void
|
||||
mobileOpen: boolean
|
||||
onMobileClose: () => void
|
||||
user: any
|
||||
onLogout: () => void
|
||||
activeTab?: string
|
||||
onTabChange?: (tab: string) => void
|
||||
}
|
||||
|
||||
function AdminSidebar({
|
||||
collapsed,
|
||||
onCollapse,
|
||||
mobileOpen,
|
||||
onMobileClose,
|
||||
user,
|
||||
onLogout,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}: AdminSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
const navItems = [
|
||||
{ id: 'overview', label: 'Overview', icon: Activity, shortcut: 'O' },
|
||||
{ id: 'users', label: 'Users', icon: Users, shortcut: 'U' },
|
||||
{ id: 'alerts', label: 'Price Alerts', icon: Bell },
|
||||
{ id: 'newsletter', label: 'Newsletter', icon: Mail },
|
||||
{ id: 'tld', label: 'TLD Data', icon: Globe },
|
||||
{ id: 'auctions', label: 'Auctions', icon: Gavel },
|
||||
{ id: 'blog', label: 'Blog', icon: BookOpen, shortcut: 'B' },
|
||||
{ id: 'system', label: 'System', icon: Database, shortcut: 'Y' },
|
||||
{ id: 'activity', label: 'Activity Log', icon: History },
|
||||
]
|
||||
|
||||
const SidebarContent = () => (
|
||||
<>
|
||||
{/* Logo */}
|
||||
<div className={clsx(
|
||||
"h-20 flex items-center border-b border-red-500/20",
|
||||
collapsed ? "justify-center px-2" : "px-5"
|
||||
)}>
|
||||
<Link href="/admin" className="flex items-center gap-3 group">
|
||||
<div className={clsx(
|
||||
"relative flex items-center justify-center",
|
||||
collapsed ? "w-10 h-10" : "w-11 h-11"
|
||||
)}>
|
||||
<div className="absolute inset-0 bg-red-500/20 blur-xl rounded-full scale-150 opacity-50 group-hover:opacity-80 transition-opacity" />
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center shadow-lg shadow-red-500/20">
|
||||
<Shield className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<span className="text-lg font-bold tracking-wide text-foreground">Admin</span>
|
||||
<span className="block text-[10px] text-red-400 uppercase tracking-wider">Control Panel</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-6 px-3 space-y-1 overflow-y-auto">
|
||||
{!collapsed && (
|
||||
<p className="px-3 mb-3 text-[10px] font-semibold text-foreground-subtle/60 uppercase tracking-[0.15em]">
|
||||
Management
|
||||
</p>
|
||||
)}
|
||||
|
||||
{navItems.map((item) => {
|
||||
const isActive = activeTab === item.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onTabChange?.(item.id)}
|
||||
className={clsx(
|
||||
"w-full group relative flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||
isActive
|
||||
? "bg-gradient-to-r from-red-500/20 to-red-500/5 text-foreground border border-red-500/20"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5 border border-transparent"
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-red-500 rounded-r-full" />
|
||||
)}
|
||||
|
||||
<item.icon className={clsx(
|
||||
"w-5 h-5 transition-colors",
|
||||
isActive ? "text-red-400" : "group-hover:text-foreground"
|
||||
)} />
|
||||
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left text-sm font-medium">{item.label}</span>
|
||||
{item.shortcut && <ShortcutHint shortcut={item.shortcut} />}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="border-t border-border/30 py-4 px-3 space-y-2">
|
||||
{/* Back to User Dashboard */}
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||
"text-accent hover:bg-accent/10 border border-transparent hover:border-accent/20"
|
||||
)}
|
||||
title={collapsed ? "Back to Dashboard" : undefined}
|
||||
>
|
||||
<LayoutDashboard className="w-5 h-5" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-sm font-medium">User Dashboard</span>
|
||||
<ShortcutHint shortcut="D" />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* User Info */}
|
||||
{!collapsed && (
|
||||
<div className="p-4 bg-red-500/5 border border-red-500/20 rounded-xl">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 bg-red-500/20 rounded-lg flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{user?.name || user?.email?.split('@')[0]}</p>
|
||||
<p className="text-xs text-red-400">Administrator</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||
"text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
title={collapsed ? "Sign out" : undefined}
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
{!collapsed && <span className="text-sm font-medium">Sign out</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Collapse Toggle */}
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
className={clsx(
|
||||
"hidden lg:flex absolute -right-3 top-24 w-6 h-6 bg-background border border-border rounded-full",
|
||||
"items-center justify-center text-foreground-muted hover:text-foreground",
|
||||
"hover:bg-red-500/10 hover:border-red-500/30 transition-all duration-300 shadow-lg"
|
||||
)}
|
||||
>
|
||||
{collapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-40 bg-background/80 backdrop-blur-sm"
|
||||
onClick={onMobileClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Sidebar */}
|
||||
<aside
|
||||
className={clsx(
|
||||
"lg:hidden fixed left-0 top-0 bottom-0 z-50 w-[280px] flex flex-col",
|
||||
"bg-background/95 backdrop-blur-xl border-r border-red-500/20",
|
||||
"transition-transform duration-300 ease-out",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={onMobileClose}
|
||||
className="absolute top-5 right-4 w-8 h-8 flex items-center justify-center text-foreground-muted hover:text-foreground"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
<aside
|
||||
className={clsx(
|
||||
"hidden lg:flex fixed left-0 top-0 bottom-0 z-40 flex-col",
|
||||
"bg-gradient-to-b from-background/95 via-background/90 to-background/95 backdrop-blur-xl",
|
||||
"border-r border-red-500/20",
|
||||
"transition-all duration-300 ease-out",
|
||||
collapsed ? "w-[80px]" : "w-[280px]"
|
||||
)}
|
||||
>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHORTCUTS WRAPPER
|
||||
// ============================================================================
|
||||
|
||||
function AdminShortcutsWrapper() {
|
||||
useAdminShortcuts()
|
||||
return null
|
||||
}
|
||||
|
||||
@ -4,7 +4,8 @@ import { useEffect, useState, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Bell, Search, X } from 'lucide-react'
|
||||
import { KeyboardShortcutsProvider, useUserShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||
import { Bell, Search, X, Command } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@ -80,18 +81,20 @@ export function CommandCenterLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* 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.02] rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.015] rounded-full blur-[100px]" />
|
||||
</div>
|
||||
<KeyboardShortcutsProvider>
|
||||
<UserShortcutsWrapper />
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* 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.02] rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.015] rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onCollapsedChange={setSidebarCollapsed}
|
||||
/>
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onCollapsedChange={setSidebarCollapsed}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div
|
||||
@ -207,6 +210,17 @@ export function CommandCenterLayout({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Hint */}
|
||||
<button
|
||||
onClick={() => {}}
|
||||
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-foreground-subtle hover:text-foreground
|
||||
bg-foreground/5 rounded-lg border border-border/50 hover:border-border transition-all"
|
||||
title="Keyboard shortcuts (?)"
|
||||
>
|
||||
<Command className="w-3 h-3" />
|
||||
<span>?</span>
|
||||
</button>
|
||||
|
||||
{/* Custom Actions */}
|
||||
{actions}
|
||||
</div>
|
||||
@ -255,9 +269,10 @@ export function CommandCenterLayout({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard shortcut for search */}
|
||||
<KeyboardShortcut onTrigger={() => setSearchOpen(true)} keys={['Meta', 'k']} />
|
||||
</div>
|
||||
{/* Keyboard shortcut for search */}
|
||||
<KeyboardShortcut onTrigger={() => setSearchOpen(true)} keys={['Meta', 'k']} />
|
||||
</div>
|
||||
</KeyboardShortcutsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -276,3 +291,9 @@ function KeyboardShortcut({ onTrigger, keys }: { onTrigger: () => void, keys: st
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// User shortcuts wrapper
|
||||
function UserShortcutsWrapper() {
|
||||
useUserShortcuts()
|
||||
return null
|
||||
}
|
||||
|
||||
423
frontend/src/components/PremiumTable.tsx
Normal file
423
frontend/src/components/PremiumTable.tsx
Normal file
@ -0,0 +1,423 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Loader2 } from 'lucide-react'
|
||||
|
||||
// ============================================================================
|
||||
// PREMIUM TABLE - Elegant, consistent styling for all tables
|
||||
// ============================================================================
|
||||
|
||||
interface Column<T> {
|
||||
key: string
|
||||
header: string | ReactNode
|
||||
render?: (item: T, index: number) => ReactNode
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
hideOnMobile?: boolean
|
||||
hideOnTablet?: boolean
|
||||
sortable?: boolean
|
||||
align?: 'left' | 'center' | 'right'
|
||||
width?: string
|
||||
}
|
||||
|
||||
interface PremiumTableProps<T> {
|
||||
data: T[]
|
||||
columns: Column<T>[]
|
||||
keyExtractor: (item: T) => string | number
|
||||
onRowClick?: (item: T) => void
|
||||
emptyState?: ReactNode
|
||||
emptyIcon?: ReactNode
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
loading?: boolean
|
||||
sortBy?: string
|
||||
sortDirection?: 'asc' | 'desc'
|
||||
onSort?: (key: string) => void
|
||||
compact?: boolean
|
||||
striped?: boolean
|
||||
hoverable?: boolean
|
||||
}
|
||||
|
||||
export function PremiumTable<T>({
|
||||
data,
|
||||
columns,
|
||||
keyExtractor,
|
||||
onRowClick,
|
||||
emptyState,
|
||||
emptyIcon,
|
||||
emptyTitle = 'No data',
|
||||
emptyDescription,
|
||||
loading,
|
||||
sortBy,
|
||||
sortDirection = 'asc',
|
||||
onSort,
|
||||
compact = false,
|
||||
striped = false,
|
||||
hoverable = true,
|
||||
}: PremiumTableProps<T>) {
|
||||
const cellPadding = compact ? 'px-4 py-3' : 'px-6 py-4'
|
||||
const headerPadding = compact ? 'px-4 py-3' : 'px-6 py-4'
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
|
||||
<div className="divide-y divide-border/20">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className={clsx("flex gap-4 items-center", cellPadding)} style={{ animationDelay: `${i * 50}ms` }}>
|
||||
<div className="h-5 w-32 bg-foreground/5 rounded-lg animate-pulse" />
|
||||
<div className="h-5 w-24 bg-foreground/5 rounded-lg animate-pulse hidden sm:block" />
|
||||
<div className="h-5 w-20 bg-foreground/5 rounded-lg animate-pulse ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
|
||||
<div className="px-8 py-16 text-center">
|
||||
{emptyState || (
|
||||
<>
|
||||
{emptyIcon && <div className="flex justify-center mb-4">{emptyIcon}</div>}
|
||||
<p className="text-foreground-muted font-medium">{emptyTitle}</p>
|
||||
{emptyDescription && <p className="text-sm text-foreground-subtle mt-1">{emptyDescription}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm shadow-[0_4px_24px_-4px_rgba(0,0,0,0.08)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/40 bg-background-secondary/30">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={clsx(
|
||||
headerPadding,
|
||||
"text-[11px] font-semibold text-foreground-subtle/70 uppercase tracking-wider",
|
||||
col.hideOnMobile && "hidden md:table-cell",
|
||||
col.hideOnTablet && "hidden lg:table-cell",
|
||||
col.align === 'right' && "text-right",
|
||||
col.align === 'center' && "text-center",
|
||||
col.headerClassName
|
||||
)}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
>
|
||||
{col.sortable && onSort ? (
|
||||
<button
|
||||
onClick={() => onSort(col.key)}
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors group"
|
||||
>
|
||||
{col.header}
|
||||
<SortIndicator
|
||||
active={sortBy === col.key}
|
||||
direction={sortBy === col.key ? sortDirection : undefined}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
col.header
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/20">
|
||||
{data.map((item, index) => {
|
||||
const key = keyExtractor(item)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={clsx(
|
||||
"group transition-all duration-200",
|
||||
onRowClick && "cursor-pointer",
|
||||
hoverable && "hover:bg-foreground/[0.02]",
|
||||
striped && index % 2 === 1 && "bg-foreground/[0.01]"
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={clsx(
|
||||
cellPadding,
|
||||
"text-sm",
|
||||
col.hideOnMobile && "hidden md:table-cell",
|
||||
col.hideOnTablet && "hidden lg:table-cell",
|
||||
col.align === 'right' && "text-right",
|
||||
col.align === 'center' && "text-center",
|
||||
col.className
|
||||
)}
|
||||
>
|
||||
{col.render
|
||||
? col.render(item, index)
|
||||
: (item as Record<string, unknown>)[col.key] as ReactNode
|
||||
}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SORT INDICATOR
|
||||
// ============================================================================
|
||||
|
||||
function SortIndicator({ active, direction }: { active: boolean; direction?: 'asc' | 'desc' }) {
|
||||
if (!active) {
|
||||
return <ChevronsUpDown className="w-3.5 h-3.5 text-foreground-subtle/50 group-hover:text-foreground-muted transition-colors" />
|
||||
}
|
||||
return direction === 'asc'
|
||||
? <ChevronUp className="w-3.5 h-3.5 text-accent" />
|
||||
: <ChevronDown className="w-3.5 h-3.5 text-accent" />
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STATUS BADGE
|
||||
// ============================================================================
|
||||
|
||||
type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'accent' | 'info'
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'sm',
|
||||
dot = false,
|
||||
pulse = false,
|
||||
}: {
|
||||
children: ReactNode
|
||||
variant?: BadgeVariant
|
||||
size?: 'xs' | 'sm' | 'md'
|
||||
dot?: boolean
|
||||
pulse?: boolean
|
||||
}) {
|
||||
const variants: Record<BadgeVariant, string> = {
|
||||
default: "bg-foreground/5 text-foreground-muted border-border/50",
|
||||
success: "bg-accent/10 text-accent border-accent/20",
|
||||
warning: "bg-amber-500/10 text-amber-400 border-amber-500/20",
|
||||
error: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
accent: "bg-accent/10 text-accent border-accent/20",
|
||||
info: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
xs: "text-[10px] px-1.5 py-0.5",
|
||||
sm: "text-xs px-2 py-0.5",
|
||||
md: "text-xs px-2.5 py-1",
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-1.5 font-medium rounded-md border",
|
||||
variants[variant],
|
||||
sizes[size]
|
||||
)}>
|
||||
{dot && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
{pulse && (
|
||||
<span className={clsx(
|
||||
"animate-ping absolute inline-flex h-full w-full rounded-full opacity-75",
|
||||
variant === 'success' || variant === 'accent' ? "bg-accent" :
|
||||
variant === 'warning' ? "bg-amber-400" :
|
||||
variant === 'error' ? "bg-red-400" : "bg-foreground"
|
||||
)} />
|
||||
)}
|
||||
<span className={clsx(
|
||||
"relative inline-flex rounded-full h-2 w-2",
|
||||
variant === 'success' || variant === 'accent' ? "bg-accent" :
|
||||
variant === 'warning' ? "bg-amber-400" :
|
||||
variant === 'error' ? "bg-red-400" :
|
||||
variant === 'info' ? "bg-blue-400" : "bg-foreground-muted"
|
||||
)} />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TABLE ACTION BUTTON
|
||||
// ============================================================================
|
||||
|
||||
export function TableActionButton({
|
||||
icon: Icon,
|
||||
onClick,
|
||||
variant = 'default',
|
||||
title,
|
||||
disabled,
|
||||
loading,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
onClick?: () => void
|
||||
variant?: 'default' | 'danger' | 'accent'
|
||||
title?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}) {
|
||||
const variants = {
|
||||
default: "text-foreground-muted hover:text-foreground hover:bg-foreground/5 border-transparent",
|
||||
danger: "text-foreground-muted hover:text-red-400 hover:bg-red-500/10 border-transparent hover:border-red-500/20",
|
||||
accent: "text-accent bg-accent/10 border-accent/20 hover:bg-accent/20",
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick?.()
|
||||
}}
|
||||
disabled={disabled || loading}
|
||||
title={title}
|
||||
className={clsx(
|
||||
"p-2 rounded-lg border transition-all duration-200",
|
||||
"disabled:opacity-30 disabled:cursor-not-allowed",
|
||||
variants[variant]
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Icon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLATFORM BADGE (for auctions)
|
||||
// ============================================================================
|
||||
|
||||
export function PlatformBadge({ platform }: { platform: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
'GoDaddy': 'text-blue-400 bg-blue-400/10 border-blue-400/20',
|
||||
'Sedo': 'text-orange-400 bg-orange-400/10 border-orange-400/20',
|
||||
'NameJet': 'text-purple-400 bg-purple-400/10 border-purple-400/20',
|
||||
'DropCatch': 'text-teal-400 bg-teal-400/10 border-teal-400/20',
|
||||
'ExpiredDomains': 'text-pink-400 bg-pink-400/10 border-pink-400/20',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-md border",
|
||||
colors[platform] || "text-foreground-muted bg-foreground/5 border-border/50"
|
||||
)}>
|
||||
{platform}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STAT CARD (for page headers)
|
||||
// ============================================================================
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
accent = false,
|
||||
trend,
|
||||
}: {
|
||||
title: string
|
||||
value: string | number
|
||||
subtitle?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
accent?: boolean
|
||||
trend?: { value: number; label?: string }
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
"relative p-5 rounded-2xl border overflow-hidden transition-all duration-300",
|
||||
accent
|
||||
? "bg-gradient-to-br from-accent/15 to-accent/5 border-accent/30"
|
||||
: "bg-gradient-to-br from-background-secondary/60 to-background-secondary/30 border-border/50 hover:border-accent/30"
|
||||
)}>
|
||||
{accent && <div className="absolute top-0 right-0 w-20 h-20 bg-accent/10 rounded-full blur-2xl" />}
|
||||
<div className="relative">
|
||||
{Icon && (
|
||||
<div className={clsx(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center mb-3",
|
||||
accent ? "bg-accent/20 border border-accent/30" : "bg-foreground/5 border border-border/30"
|
||||
)}>
|
||||
<Icon className={clsx("w-5 h-5", accent ? "text-accent" : "text-foreground-muted")} />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-foreground-subtle uppercase tracking-wider mb-1">{title}</p>
|
||||
<p className={clsx("text-2xl font-display", accent ? "text-accent" : "text-foreground")}>
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</p>
|
||||
{subtitle && <p className="text-xs text-foreground-subtle mt-0.5">{subtitle}</p>}
|
||||
{trend && (
|
||||
<div className={clsx(
|
||||
"inline-flex items-center gap-1 mt-2 text-xs font-medium px-2 py-0.5 rounded",
|
||||
trend.value > 0 ? "text-accent bg-accent/10" : trend.value < 0 ? "text-red-400 bg-red-400/10" : "text-foreground-muted bg-foreground/5"
|
||||
)}>
|
||||
{trend.value > 0 ? '+' : ''}{trend.value}%
|
||||
{trend.label && <span className="text-foreground-subtle">{trend.label}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PAGE CONTAINER (consistent max-width)
|
||||
// ============================================================================
|
||||
|
||||
export function PageContainer({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={clsx("max-w-7xl mx-auto space-y-6", className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SECTION HEADER
|
||||
// ============================================================================
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{Icon && (
|
||||
<div className="w-10 h-10 bg-accent/10 border border-accent/20 rounded-xl flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
{subtitle && <p className="text-sm text-foreground-muted">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
311
frontend/src/hooks/useKeyboardShortcuts.tsx
Normal file
311
frontend/src/hooks/useKeyboardShortcuts.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useCallback, useState, createContext, useContext, ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { X, Command, Search } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface Shortcut {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
action: () => void
|
||||
category: 'navigation' | 'actions' | 'global'
|
||||
requiresModifier?: boolean
|
||||
}
|
||||
|
||||
interface KeyboardShortcutsContextType {
|
||||
shortcuts: Shortcut[]
|
||||
registerShortcut: (shortcut: Shortcut) => void
|
||||
unregisterShortcut: (key: string) => void
|
||||
showHelp: boolean
|
||||
setShowHelp: (show: boolean) => void
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTEXT
|
||||
// ============================================================================
|
||||
|
||||
const KeyboardShortcutsContext = createContext<KeyboardShortcutsContextType | null>(null)
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const context = useContext(KeyboardShortcutsContext)
|
||||
if (!context) {
|
||||
throw new Error('useKeyboardShortcuts must be used within KeyboardShortcutsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROVIDER
|
||||
// ============================================================================
|
||||
|
||||
export function KeyboardShortcutsProvider({
|
||||
children,
|
||||
shortcuts: defaultShortcuts = [],
|
||||
}: {
|
||||
children: ReactNode
|
||||
shortcuts?: Shortcut[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [shortcuts, setShortcuts] = useState<Shortcut[]>(defaultShortcuts)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
const registerShortcut = useCallback((shortcut: Shortcut) => {
|
||||
setShortcuts(prev => {
|
||||
const existing = prev.find(s => s.key === shortcut.key)
|
||||
if (existing) return prev
|
||||
return [...prev, shortcut]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const unregisterShortcut = useCallback((key: string) => {
|
||||
setShortcuts(prev => prev.filter(s => s.key !== key))
|
||||
}, [])
|
||||
|
||||
// Handle keyboard events
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if user is typing in an input
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement ||
|
||||
(e.target as HTMLElement)?.isContentEditable
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Show help with ?
|
||||
if (e.key === '?' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
setShowHelp(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Close help with Escape
|
||||
if (e.key === 'Escape' && showHelp) {
|
||||
e.preventDefault()
|
||||
setShowHelp(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Find matching shortcut
|
||||
const shortcut = shortcuts.find(s => {
|
||||
if (s.requiresModifier) {
|
||||
return (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === s.key.toLowerCase()
|
||||
}
|
||||
return e.key.toLowerCase() === s.key.toLowerCase() && !e.metaKey && !e.ctrlKey
|
||||
})
|
||||
|
||||
if (shortcut) {
|
||||
e.preventDefault()
|
||||
shortcut.action()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [shortcuts, showHelp])
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsContext.Provider value={{ shortcuts, registerShortcut, unregisterShortcut, showHelp, setShowHelp }}>
|
||||
{children}
|
||||
{showHelp && <ShortcutsModal shortcuts={shortcuts} onClose={() => setShowHelp(false)} />}
|
||||
</KeyboardShortcutsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHORTCUTS MODAL
|
||||
// ============================================================================
|
||||
|
||||
function ShortcutsModal({ shortcuts, onClose }: { shortcuts: Shortcut[]; onClose: () => void }) {
|
||||
const categories = {
|
||||
navigation: shortcuts.filter(s => s.category === 'navigation'),
|
||||
actions: shortcuts.filter(s => s.category === 'actions'),
|
||||
global: shortcuts.filter(s => s.category === 'global'),
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg bg-background border border-border/50 rounded-2xl shadow-2xl overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-background-secondary/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-accent/10 rounded-xl flex items-center justify-center">
|
||||
<Command className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Keyboard Shortcuts</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-foreground-muted hover:text-foreground rounded-lg hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 max-h-[60vh] overflow-y-auto space-y-6">
|
||||
{/* Navigation */}
|
||||
{categories.navigation.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-foreground-subtle uppercase tracking-wider mb-3">Navigation</h3>
|
||||
<div className="space-y-2">
|
||||
{categories.navigation.map(shortcut => (
|
||||
<ShortcutRow key={shortcut.key} shortcut={shortcut} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{categories.actions.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-foreground-subtle uppercase tracking-wider mb-3">Actions</h3>
|
||||
<div className="space-y-2">
|
||||
{categories.actions.map(shortcut => (
|
||||
<ShortcutRow key={shortcut.key} shortcut={shortcut} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global */}
|
||||
{categories.global.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-foreground-subtle uppercase tracking-wider mb-3">Global</h3>
|
||||
<div className="space-y-2">
|
||||
{categories.global.map(shortcut => (
|
||||
<ShortcutRow key={shortcut.key} shortcut={shortcut} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-border/50 bg-background-secondary/30">
|
||||
<p className="text-xs text-foreground-subtle text-center">
|
||||
Press <kbd className="px-1.5 py-0.5 bg-foreground/10 rounded text-foreground-muted">?</kbd> anytime to show this help
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShortcutRow({ shortcut }: { shortcut: Shortcut }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-foreground/5 transition-colors">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{shortcut.label}</p>
|
||||
<p className="text-xs text-foreground-subtle">{shortcut.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.requiresModifier && (
|
||||
<>
|
||||
<kbd className="px-2 py-1 bg-foreground/10 rounded text-xs font-mono text-foreground-muted">⌘</kbd>
|
||||
<span className="text-foreground-subtle">+</span>
|
||||
</>
|
||||
)}
|
||||
<kbd className="px-2 py-1 bg-foreground/10 rounded text-xs font-mono text-foreground-muted uppercase">
|
||||
{shortcut.key}
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER BACKEND SHORTCUTS
|
||||
// ============================================================================
|
||||
|
||||
export function useUserShortcuts() {
|
||||
const router = useRouter()
|
||||
const { registerShortcut, unregisterShortcut, setShowHelp } = useKeyboardShortcuts()
|
||||
|
||||
useEffect(() => {
|
||||
const userShortcuts: Shortcut[] = [
|
||||
// Navigation
|
||||
{ key: 'g', label: 'Go to Dashboard', description: 'Navigate to dashboard', action: () => router.push('/dashboard'), category: 'navigation' },
|
||||
{ key: 'w', label: 'Go to Watchlist', description: 'Navigate to watchlist', action: () => router.push('/watchlist'), category: 'navigation' },
|
||||
{ key: 'p', label: 'Go to Portfolio', description: 'Navigate to portfolio', action: () => router.push('/portfolio'), category: 'navigation' },
|
||||
{ key: 'a', label: 'Go to Auctions', description: 'Navigate to auctions', action: () => router.push('/auctions'), category: 'navigation' },
|
||||
{ key: 'i', label: 'Go to Intelligence', description: 'Navigate to TLD intelligence', action: () => router.push('/intelligence'), category: 'navigation' },
|
||||
{ key: 's', label: 'Go to Settings', description: 'Navigate to settings', action: () => router.push('/settings'), category: 'navigation' },
|
||||
// Actions
|
||||
{ key: 'n', label: 'Add Domain', description: 'Quick add a new domain', action: () => document.querySelector<HTMLInputElement>('input[placeholder*="domain"]')?.focus(), category: 'actions' },
|
||||
{ key: 'k', label: 'Search', description: 'Focus search input', action: () => document.querySelector<HTMLInputElement>('input[type="text"]')?.focus(), category: 'actions', requiresModifier: true },
|
||||
// Global
|
||||
{ key: '?', label: 'Show Shortcuts', description: 'Display this help', action: () => setShowHelp(true), category: 'global' },
|
||||
{ key: 'Escape', label: 'Close Modal', description: 'Close any open modal', action: () => {}, category: 'global' },
|
||||
]
|
||||
|
||||
userShortcuts.forEach(registerShortcut)
|
||||
|
||||
return () => {
|
||||
userShortcuts.forEach(s => unregisterShortcut(s.key))
|
||||
}
|
||||
}, [router, registerShortcut, unregisterShortcut, setShowHelp])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ADMIN SHORTCUTS
|
||||
// ============================================================================
|
||||
|
||||
export function useAdminShortcuts() {
|
||||
const router = useRouter()
|
||||
const { registerShortcut, unregisterShortcut, setShowHelp } = useKeyboardShortcuts()
|
||||
|
||||
useEffect(() => {
|
||||
const adminShortcuts: Shortcut[] = [
|
||||
// Navigation
|
||||
{ key: 'o', label: 'Overview', description: 'Go to admin overview', action: () => {}, category: 'navigation' },
|
||||
{ key: 'u', label: 'Users', description: 'Go to users management', action: () => {}, category: 'navigation' },
|
||||
{ key: 'b', label: 'Blog', description: 'Go to blog management', action: () => {}, category: 'navigation' },
|
||||
{ key: 'y', label: 'System', description: 'Go to system status', action: () => {}, category: 'navigation' },
|
||||
// Actions
|
||||
{ key: 'r', label: 'Refresh Data', description: 'Refresh current data', action: () => window.location.reload(), category: 'actions' },
|
||||
{ key: 'e', label: 'Export', description: 'Export current data', action: () => {}, category: 'actions' },
|
||||
// Global
|
||||
{ key: '?', label: 'Show Shortcuts', description: 'Display this help', action: () => setShowHelp(true), category: 'global' },
|
||||
{ key: 'd', label: 'Back to Dashboard', description: 'Return to user dashboard', action: () => router.push('/dashboard'), category: 'global' },
|
||||
]
|
||||
|
||||
adminShortcuts.forEach(registerShortcut)
|
||||
|
||||
return () => {
|
||||
adminShortcuts.forEach(s => unregisterShortcut(s.key))
|
||||
}
|
||||
}, [router, registerShortcut, unregisterShortcut, setShowHelp])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHORTCUT HINT COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function ShortcutHint({ shortcut, className }: { shortcut: string; className?: string }) {
|
||||
return (
|
||||
<kbd className={clsx(
|
||||
"hidden sm:inline-flex items-center justify-center",
|
||||
"px-1.5 py-0.5 text-[10px] font-mono uppercase",
|
||||
"bg-foreground/5 text-foreground-subtle border border-border/50 rounded",
|
||||
className
|
||||
)}>
|
||||
{shortcut}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user