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'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { AdminLayout } from '@/components/AdminLayout'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { PremiumTable, Badge, TableActionButton, StatCard, PageContainer } from '@/components/PremiumTable'
|
||||||
import { DataTable, StatusBadge, TableAction } from '@/components/DataTable'
|
|
||||||
import { useStore } from '@/lib/store'
|
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
Database,
|
|
||||||
Mail,
|
Mail,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Shield,
|
Shield,
|
||||||
@ -29,14 +26,13 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
Download,
|
Download,
|
||||||
Send,
|
Send,
|
||||||
Clock,
|
|
||||||
History,
|
History,
|
||||||
X,
|
X,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Plus,
|
Plus,
|
||||||
Edit2,
|
Edit2,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Sparkles,
|
Database,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
@ -66,9 +62,6 @@ interface AdminUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const router = useRouter()
|
|
||||||
const { user, isAuthenticated, isLoading } = useStore()
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
||||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||||
const [users, setUsers] = useState<AdminUser[]>([])
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
@ -95,16 +88,8 @@ export default function AdminPage() {
|
|||||||
const [bulkTier, setBulkTier] = useState('trader')
|
const [bulkTier, setBulkTier] = useState('trader')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isAuthenticated) {
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
}, [isLoading, isAuthenticated, router])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAuthenticated) {
|
|
||||||
loadAdminData()
|
loadAdminData()
|
||||||
}
|
}, [activeTab])
|
||||||
}, [isAuthenticated, activeTab])
|
|
||||||
|
|
||||||
const loadAdminData = async () => {
|
const loadAdminData = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@ -127,33 +112,23 @@ export default function AdminPage() {
|
|||||||
setNewsletter(nlData.subscribers)
|
setNewsletter(nlData.subscribers)
|
||||||
setNewsletterTotal(nlData.total)
|
setNewsletterTotal(nlData.total)
|
||||||
} else if (activeTab === 'system') {
|
} else if (activeTab === 'system') {
|
||||||
try {
|
|
||||||
const [healthData, schedulerData] = await Promise.all([
|
const [healthData, schedulerData] = await Promise.all([
|
||||||
api.getSystemHealth(),
|
api.getSystemHealth().catch(() => null),
|
||||||
api.getSchedulerStatus(),
|
api.getSchedulerStatus().catch(() => null),
|
||||||
])
|
])
|
||||||
setSystemHealth(healthData)
|
setSystemHealth(healthData)
|
||||||
setSchedulerStatus(schedulerData)
|
setSchedulerStatus(schedulerData)
|
||||||
} catch { /* ignore */ }
|
|
||||||
} else if (activeTab === 'activity') {
|
} else if (activeTab === 'activity') {
|
||||||
try {
|
const logData = await api.getActivityLog(50, 0).catch(() => ({ logs: [], total: 0 }))
|
||||||
const logData = await api.getActivityLog(50, 0)
|
|
||||||
setActivityLog(logData.logs)
|
setActivityLog(logData.logs)
|
||||||
setActivityLogTotal(logData.total)
|
setActivityLogTotal(logData.total)
|
||||||
} catch { /* ignore */ }
|
|
||||||
} else if (activeTab === 'blog') {
|
} else if (activeTab === 'blog') {
|
||||||
try {
|
const blogData = await api.getAdminBlogPosts(50, 0).catch(() => ({ posts: [], total: 0 }))
|
||||||
const blogData = await api.getAdminBlogPosts(50, 0)
|
|
||||||
setBlogPosts(blogData.posts)
|
setBlogPosts(blogData.posts)
|
||||||
setBlogPostsTotal(blogData.total)
|
setBlogPostsTotal(blogData.total)
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -161,10 +136,9 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const handleTriggerScrape = async () => {
|
const handleTriggerScrape = async () => {
|
||||||
setScraping(true)
|
setScraping(true)
|
||||||
setError(null)
|
|
||||||
try {
|
try {
|
||||||
const result = await api.triggerTldScrape()
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Scrape failed')
|
setError(err instanceof Error ? err.message : 'Scrape failed')
|
||||||
} finally {
|
} finally {
|
||||||
@ -174,10 +148,9 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const handleTriggerAuctionScrape = async () => {
|
const handleTriggerAuctionScrape = async () => {
|
||||||
setAuctionScraping(true)
|
setAuctionScraping(true)
|
||||||
setError(null)
|
|
||||||
try {
|
try {
|
||||||
const result = await api.triggerAuctionScrape()
|
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()
|
loadAdminData()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Auction scrape failed')
|
setError(err instanceof Error ? err.message : 'Auction scrape failed')
|
||||||
@ -188,10 +161,9 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const handleTriggerDomainChecks = async () => {
|
const handleTriggerDomainChecks = async () => {
|
||||||
setDomainChecking(true)
|
setDomainChecking(true)
|
||||||
setError(null)
|
|
||||||
try {
|
try {
|
||||||
const result = await api.triggerDomainChecks()
|
const result = await api.triggerDomainChecks()
|
||||||
setSuccess(`Domain checks started: ${result.domains_queued} domains queued`)
|
setSuccess(`Domain checks started: ${result.domains_queued} queued`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Domain check failed')
|
setError(err instanceof Error ? err.message : 'Domain check failed')
|
||||||
} finally {
|
} finally {
|
||||||
@ -201,7 +173,6 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const handleSendTestEmail = async () => {
|
const handleSendTestEmail = async () => {
|
||||||
setSendingEmail(true)
|
setSendingEmail(true)
|
||||||
setError(null)
|
|
||||||
try {
|
try {
|
||||||
const result = await api.sendTestEmail()
|
const result = await api.sendTestEmail()
|
||||||
setSuccess(`Test email sent to ${result.sent_to}`)
|
setSuccess(`Test email sent to ${result.sent_to}`)
|
||||||
@ -225,39 +196,24 @@ export default function AdminPage() {
|
|||||||
const handleToggleAdmin = async (userId: number, isAdmin: boolean) => {
|
const handleToggleAdmin = async (userId: number, isAdmin: boolean) => {
|
||||||
try {
|
try {
|
||||||
await api.updateAdminUser(userId, { is_admin: !isAdmin })
|
await api.updateAdminUser(userId, { is_admin: !isAdmin })
|
||||||
setSuccess(isAdmin ? 'Admin privileges removed' : 'Admin privileges granted')
|
setSuccess(isAdmin ? 'Admin removed' : 'Admin granted')
|
||||||
loadAdminData()
|
loadAdminData()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Update failed')
|
setError(err instanceof Error ? err.message : 'Update failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteUser = async (userId: number, userEmail: string) => {
|
const handleDeleteUser = async (userId: number, email: string) => {
|
||||||
if (!confirm(`Delete user "${userEmail}" and ALL their data?\n\nThis cannot be undone.`)) return
|
if (!confirm(`Delete user "${email}"? This cannot be undone.`)) return
|
||||||
try {
|
try {
|
||||||
await api.deleteAdminUser(userId)
|
await api.deleteAdminUser(userId)
|
||||||
setSuccess(`User ${userEmail} deleted`)
|
setSuccess(`User ${email} deleted`)
|
||||||
loadAdminData()
|
loadAdminData()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Delete failed')
|
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 () => {
|
const handleExportUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await api.exportUsersCSV()
|
const result = await api.exportUsersCSV()
|
||||||
@ -265,7 +221,7 @@ export default function AdminPage() {
|
|||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
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()
|
a.click()
|
||||||
setSuccess(`Exported ${result.count} users`)
|
setSuccess(`Exported ${result.count} users`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -273,112 +229,64 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
<AdminLayout
|
||||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
title={activeTab === 'overview' ? 'Overview' :
|
||||||
</div>
|
activeTab === 'users' ? 'User Management' :
|
||||||
)
|
activeTab === 'alerts' ? 'Price Alerts' :
|
||||||
}
|
activeTab === 'newsletter' ? 'Newsletter' :
|
||||||
|
activeTab === 'tld' ? 'TLD Data' :
|
||||||
if (error && error.includes('403')) {
|
activeTab === 'auctions' ? 'Auctions' :
|
||||||
return (
|
activeTab === 'blog' ? 'Blog Management' :
|
||||||
<CommandCenterLayout title="Access Denied">
|
activeTab === 'system' ? 'System Status' :
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
'Activity Log'}
|
||||||
<Shield className="w-16 h-16 text-red-400 mb-6" />
|
subtitle="Admin Control Panel"
|
||||||
<h2 className="text-2xl font-display text-foreground mb-4">Admin Access Required</h2>
|
activeTab={activeTab}
|
||||||
<p className="text-foreground-muted mb-6">You don't have admin privileges.</p>
|
onTabChange={(tab) => setActiveTab(tab as TabType)}
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
|
<PageContainer>
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
{error && !error.includes('403') && (
|
{error && (
|
||||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
|
<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" />
|
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||||
<p className="text-sm text-red-400 flex-1">{error}</p>
|
<p className="text-sm text-red-400 flex-1">{error}</p>
|
||||||
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
|
<button onClick={() => setError(null)} className="text-red-400"><X className="w-4 h-4" /></button>
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{success && (
|
{success && (
|
||||||
<div className="mb-6 p-4 bg-accent/10 border border-accent/20 rounded-xl flex items-center gap-3">
|
<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" />
|
<Check className="w-5 h-5 text-accent shrink-0" />
|
||||||
<p className="text-sm text-accent flex-1">{success}</p>
|
<p className="text-sm text-accent flex-1">{success}</p>
|
||||||
<button onClick={() => setSuccess(null)} className="text-accent hover:text-accent/80">
|
<button onClick={() => setSuccess(null)} className="text-accent"><X className="w-4 h-4" /></button>
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</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 ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
<Loader2 className="w-6 h-6 text-red-400 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Overview Tab */}
|
{/* Overview Tab */}
|
||||||
{activeTab === 'overview' && stats && (
|
{activeTab === 'overview' && stats && (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<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="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="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="TLDs" value={stats.tld_data.unique_tlds} subtitle={`${stats.tld_data.price_records.toLocaleString()} prices`} icon={Globe} />
|
||||||
<StatCard title="Newsletter" value={stats.newsletter_subscribers} subtitle="active subscribers" icon={Mail} />
|
<StatCard title="Newsletter" value={stats.newsletter_subscribers} icon={Mail} accent />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-3 gap-4">
|
<div className="grid lg:grid-cols-3 gap-4">
|
||||||
{['scout', 'trader', 'tycoon'].map((tier) => (
|
{[
|
||||||
|
{ 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 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">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
{tier === 'tycoon' ? <Crown className="w-5 h-5 text-amber-400" /> :
|
<Icon className={clsx("w-5 h-5", color)} />
|
||||||
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>
|
<span className="text-sm font-medium text-foreground-muted capitalize">{tier}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-3xl font-display text-foreground">{stats.subscriptions[tier] || 0}</p>
|
<p className="text-3xl font-display text-foreground">{stats.subscriptions[tier] || 0}</p>
|
||||||
@ -390,12 +298,10 @@ export default function AdminPage() {
|
|||||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
<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>
|
<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-4xl font-display text-foreground">{stats.auctions.toLocaleString()}</p>
|
||||||
<p className="text-sm text-foreground-subtle mt-1">from all platforms</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
<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>
|
<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-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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -403,7 +309,7 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{/* Users Tab */}
|
{/* Users Tab */}
|
||||||
{activeTab === 'users' && (
|
{activeTab === 'users' && (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<div className="relative flex-1 max-w-md">
|
<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" />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||||
@ -413,48 +319,15 @@ export default function AdminPage() {
|
|||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && loadAdminData()}
|
onKeyDown={(e) => e.key === 'Enter' && loadAdminData()}
|
||||||
placeholder="Search users..."
|
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"
|
className="w-full pl-11 pr-4 py-3 bg-background-secondary/50 border border-border/30 rounded-xl text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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">
|
||||||
onClick={handleExportUsers}
|
<Download className="w-4 h-4" /> Export CSV
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedUsers.length > 0 && (
|
<PremiumTable
|
||||||
<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}
|
data={users}
|
||||||
keyExtractor={(u) => u.id}
|
keyExtractor={(u) => u.id}
|
||||||
selectable
|
selectable
|
||||||
@ -462,7 +335,7 @@ export default function AdminPage() {
|
|||||||
onSelectionChange={(ids) => setSelectedUsers(ids as number[])}
|
onSelectionChange={(ids) => setSelectedUsers(ids as number[])}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
key: 'email',
|
key: 'user',
|
||||||
header: 'User',
|
header: 'User',
|
||||||
render: (u) => (
|
render: (u) => (
|
||||||
<div>
|
<div>
|
||||||
@ -476,10 +349,10 @@ export default function AdminPage() {
|
|||||||
header: 'Status',
|
header: 'Status',
|
||||||
hideOnMobile: true,
|
hideOnMobile: true,
|
||||||
render: (u) => (
|
render: (u) => (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
{u.is_admin && <StatusBadge status="Admin" variant="accent" />}
|
{u.is_admin && <Badge variant="accent" size="xs">Admin</Badge>}
|
||||||
{u.is_verified && <StatusBadge status="Verified" variant="success" />}
|
{u.is_verified && <Badge variant="success" size="xs">Verified</Badge>}
|
||||||
{!u.is_active && <StatusBadge status="Inactive" variant="error" />}
|
{!u.is_active && <Badge variant="error" size="xs">Inactive</Badge>}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -487,10 +360,9 @@ export default function AdminPage() {
|
|||||||
key: 'tier',
|
key: 'tier',
|
||||||
header: 'Tier',
|
header: 'Tier',
|
||||||
render: (u) => (
|
render: (u) => (
|
||||||
<StatusBadge
|
<Badge variant={u.subscription.tier === 'tycoon' ? 'warning' : u.subscription.tier === 'trader' ? 'accent' : 'default'} size="sm">
|
||||||
status={u.subscription.tier_name}
|
{u.subscription.tier_name}
|
||||||
variant={u.subscription.tier === 'tycoon' ? 'warning' : u.subscription.tier === 'trader' ? 'accent' : 'default'}
|
</Badge>
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -502,143 +374,65 @@ export default function AdminPage() {
|
|||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
className: 'text-right',
|
align: 'right',
|
||||||
headerClassName: 'text-right',
|
|
||||||
render: (u) => (
|
render: (u) => (
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<select
|
<select
|
||||||
value={u.subscription.tier}
|
value={u.subscription.tier}
|
||||||
onChange={(e) => handleUpgradeUser(u.id, e.target.value)}
|
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"
|
className="px-2 py-1.5 bg-background-secondary border border-border/30 rounded-lg text-xs"
|
||||||
>
|
>
|
||||||
<option value="scout">Scout</option>
|
<option value="scout">Scout</option>
|
||||||
<option value="trader">Trader</option>
|
<option value="trader">Trader</option>
|
||||||
<option value="tycoon">Tycoon</option>
|
<option value="tycoon">Tycoon</option>
|
||||||
</select>
|
</select>
|
||||||
<TableAction
|
<TableActionButton icon={Shield} onClick={() => handleToggleAdmin(u.id, u.is_admin)} variant={u.is_admin ? 'accent' : 'default'} title={u.is_admin ? 'Remove admin' : 'Make admin'} />
|
||||||
icon={Shield}
|
<TableActionButton icon={Trash2} onClick={() => handleDeleteUser(u.id, u.email)} variant="danger" disabled={u.is_admin} title="Delete user" />
|
||||||
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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
emptyState={
|
emptyIcon={<Users className="w-12 h-12 text-foreground-subtle" />}
|
||||||
<div className="text-center">
|
emptyTitle="No users found"
|
||||||
<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>
|
<p className="text-sm text-foreground-subtle">Showing {users.length} of {usersTotal} users</p>
|
||||||
</div>
|
</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 */}
|
{/* Newsletter Tab */}
|
||||||
{activeTab === 'newsletter' && (
|
{activeTab === 'newsletter' && (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-foreground-muted">{newsletterTotal} subscribers</p>
|
<p className="text-sm text-foreground-muted">{newsletterTotal} subscribers</p>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
|
||||||
const data = await api.exportNewsletterEmails()
|
const data = await api.exportNewsletterEmails()
|
||||||
const blob = new Blob([data.emails.join('\n')], { type: 'text/plain' })
|
const blob = new Blob([data.emails.join('\n')], { type: 'text/plain' })
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = URL.createObjectURL(blob)
|
||||||
a.download = 'newsletter-emails.txt'
|
a.download = 'newsletter-emails.txt'
|
||||||
a.click()
|
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"
|
className="px-5 py-2.5 bg-red-500 text-white rounded-xl text-sm font-medium hover:bg-red-600"
|
||||||
>
|
>
|
||||||
Export Emails
|
Export Emails
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<PremiumTable
|
||||||
data={newsletter}
|
data={newsletter}
|
||||||
keyExtractor={(s) => s.id}
|
keyExtractor={(s) => s.id}
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'email', header: 'Email', render: (s) => <span className="text-foreground">{s.email}</span> },
|
{ 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: 'status', header: 'Status', render: (s) => <Badge variant={s.is_active ? 'success' : 'error'} dot>{s.is_active ? 'Active' : 'Unsubscribed'}</Badge> },
|
||||||
{ key: 'subscribed_at', header: 'Subscribed', render: (s) => new Date(s.subscribed_at).toLocaleDateString() },
|
{ key: 'subscribed', header: 'Subscribed', render: (s) => new Date(s.subscribed_at).toLocaleDateString() },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* System Tab */}
|
||||||
{activeTab === 'system' && (
|
{activeTab === 'system' && (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<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">
|
<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>
|
<h3 className="text-lg font-medium text-foreground mb-4">System Status</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -663,14 +457,22 @@ export default function AdminPage() {
|
|||||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
<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>
|
<h3 className="text-lg font-medium text-foreground mb-4">Manual Triggers</h3>
|
||||||
<div className="space-y-3">
|
<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">
|
<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 ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||||
{domainChecking ? 'Checking...' : 'Check All Domains'}
|
{domainChecking ? 'Checking...' : 'Check All Domains'}
|
||||||
</button>
|
</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">
|
<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 ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||||
{sendingEmail ? 'Sending...' : 'Send Test Email'}
|
{sendingEmail ? 'Sending...' : 'Send Test Email'}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
@ -678,7 +480,7 @@ export default function AdminPage() {
|
|||||||
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
<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">Scheduled Jobs</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{schedulerStatus.jobs?.slice(0, 4).map((job: any) => (
|
{schedulerStatus.jobs?.slice(0, 5).map((job: any) => (
|
||||||
<div key={job.id} className="flex items-center justify-between text-sm">
|
<div key={job.id} className="flex items-center justify-between text-sm">
|
||||||
<span className="text-foreground truncate">{job.name}</span>
|
<span className="text-foreground truncate">{job.name}</span>
|
||||||
<span className="text-foreground-subtle text-xs">{job.trigger}</span>
|
<span className="text-foreground-subtle text-xs">{job.trigger}</span>
|
||||||
@ -691,73 +493,70 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Blog Tab */}
|
{/* 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) => <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: 'time', header: 'Time', hideOnMobile: true, render: (l) => new Date(l.created_at).toLocaleString() },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'blog' && (
|
{activeTab === 'blog' && (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-foreground-muted">{blogPostsTotal} blog posts</p>
|
<p className="text-sm text-foreground-muted">{blogPostsTotal} 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">
|
<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" />
|
<Plus className="w-4 h-4" /> New Post
|
||||||
New Post
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<PremiumTable
|
||||||
data={blogPosts}
|
data={blogPosts}
|
||||||
keyExtractor={(p) => p.id}
|
keyExtractor={(p) => p.id}
|
||||||
|
emptyIcon={<BookOpen className="w-12 h-12 text-foreground-subtle" />}
|
||||||
|
emptyTitle="No blog posts"
|
||||||
columns={[
|
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: '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 ? <StatusBadge status={p.category} /> : '—' },
|
{ key: 'category', header: 'Category', hideOnMobile: true, render: (p) => p.category ? <Badge>{p.category}</Badge> : '—' },
|
||||||
{ key: 'is_published', header: 'Status', render: (p) => <StatusBadge status={p.is_published ? 'Published' : 'Draft'} variant={p.is_published ? 'success' : 'warning'} /> },
|
{ key: 'status', header: 'Status', render: (p) => <Badge variant={p.is_published ? 'success' : 'warning'}>{p.is_published ? 'Published' : 'Draft'}</Badge> },
|
||||||
{ key: 'view_count', header: 'Views', hideOnMobile: true, render: (p) => p.view_count },
|
{ key: 'views', header: 'Views', hideOnMobile: true, render: (p) => p.view_count },
|
||||||
{ key: 'actions', header: 'Actions', className: 'text-right', headerClassName: 'text-right', render: (p) => (
|
{ key: 'actions', header: '', align: 'right', render: (p) => (
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<TableAction icon={ExternalLink} onClick={() => window.open(`/blog/${p.slug}`, '_blank')} title="View" />
|
<TableActionButton icon={ExternalLink} onClick={() => window.open(`/blog/${p.slug}`, '_blank')} />
|
||||||
<TableAction icon={Edit2} title="Edit" />
|
<TableActionButton icon={Edit2} />
|
||||||
<TableAction icon={Trash2} variant="danger" title="Delete" />
|
<TableActionButton icon={Trash2} variant="danger" />
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activity Tab */}
|
|
||||||
{activeTab === 'activity' && (
|
|
||||||
<div className="space-y-6 animate-fade-in">
|
|
||||||
<p className="text-sm text-foreground-muted">{activityLogTotal} log entries</p>
|
|
||||||
<DataTable
|
|
||||||
data={activityLog}
|
|
||||||
keyExtractor={(l) => l.id}
|
|
||||||
columns={[
|
|
||||||
{ key: 'action', header: 'Action', render: (l) => <StatusBadge status={l.action} /> },
|
|
||||||
{ 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() },
|
|
||||||
]}
|
|
||||||
emptyState={<><History className="w-12 h-12 text-foreground-subtle mx-auto mb-4" /><p className="text-foreground-muted">No activity logged</p></>}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CommandCenterLayout>
|
</PageContainer>
|
||||||
)
|
</AdminLayout>
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Header } from '@/components/Header'
|
|
||||||
import { Footer } from '@/components/Footer'
|
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
|
import { PremiumTable, Badge, PlatformBadge, StatCard, PageContainer } from '@/components/PremiumTable'
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
TrendingUp,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Search,
|
Search,
|
||||||
Flame,
|
Flame,
|
||||||
Timer,
|
Timer,
|
||||||
Users,
|
|
||||||
ArrowUpRight,
|
|
||||||
Lock,
|
|
||||||
Gavel,
|
Gavel,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@ -22,8 +18,9 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Target,
|
Target,
|
||||||
Info,
|
|
||||||
X,
|
X,
|
||||||
|
TrendingUp,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@ -54,11 +51,6 @@ interface Opportunity {
|
|||||||
price_range?: string
|
price_range?: string
|
||||||
recommendation: string
|
recommendation: string
|
||||||
reasoning?: 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: 'Sedo', name: 'Sedo' },
|
||||||
{ id: 'NameJet', name: 'NameJet' },
|
{ id: 'NameJet', name: 'NameJet' },
|
||||||
{ id: 'DropCatch', name: 'DropCatch' },
|
{ 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() {
|
export default function AuctionsPage() {
|
||||||
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
const { isAuthenticated, subscription } = useStore()
|
||||||
|
|
||||||
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
|
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
|
||||||
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
|
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
|
||||||
@ -131,9 +84,8 @@ export default function AuctionsPage() {
|
|||||||
const [maxBid, setMaxBid] = useState<string>('')
|
const [maxBid, setMaxBid] = useState<string>('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuth()
|
|
||||||
loadData()
|
loadData()
|
||||||
}, [checkAuth])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && opportunities.length === 0) {
|
if (isAuthenticated && opportunities.length === 0) {
|
||||||
@ -203,15 +155,9 @@ export default function AuctionsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filteredAuctions = getCurrentAuctions().filter(auction => {
|
const filteredAuctions = getCurrentAuctions().filter(auction => {
|
||||||
if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) {
|
if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
||||||
return false
|
if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) return false
|
||||||
}
|
if (maxBid && auction.current_bid > parseFloat(maxBid)) return false
|
||||||
if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (maxBid && auction.current_bid > parseFloat(maxBid)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -233,12 +179,8 @@ export default function AuctionsPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const getTimeColor = (timeRemaining: string) => {
|
const getTimeColor = (timeRemaining: string) => {
|
||||||
if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) {
|
if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) return 'text-red-400'
|
||||||
return 'text-danger'
|
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) return 'text-amber-400'
|
||||||
}
|
|
||||||
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) {
|
|
||||||
return 'text-warning'
|
|
||||||
}
|
|
||||||
return 'text-foreground-muted'
|
return 'text-foreground-muted'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,175 +193,76 @@ export default function AuctionsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
<CommandCenterLayout
|
||||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
title="Auctions"
|
||||||
</div>
|
subtitle="Real-time from GoDaddy, Sedo, NameJet & DropCatch"
|
||||||
)
|
actions={
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
<Header />
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</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
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
title="Refresh auction data from all platforms"
|
className="flex items-center gap-2 px-4 py-2 text-sm text-foreground-muted hover:text-foreground
|
||||||
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"
|
hover:bg-foreground/5 rounded-lg transition-all disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||||
Refresh
|
<span className="hidden sm:inline">Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Search & Filters */}
|
{/* Tabs */}
|
||||||
<div className="mb-6 animate-slide-up">
|
<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>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
<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" />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search domains..."
|
placeholder="Search domains..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-12 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl
|
className="w-full pl-11 pr-10 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||||
text-body text-foreground placeholder:text-foreground-subtle
|
text-sm text-foreground placeholder:text-foreground-subtle
|
||||||
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent
|
focus:outline-none focus:border-accent/50 transition-all"
|
||||||
transition-all duration-300"
|
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button onClick={() => setSearchQuery('')} className="absolute right-4 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground">
|
||||||
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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -427,265 +270,148 @@ export default function AuctionsPage() {
|
|||||||
<select
|
<select
|
||||||
value={selectedPlatform}
|
value={selectedPlatform}
|
||||||
onChange={(e) => setSelectedPlatform(e.target.value)}
|
onChange={(e) => setSelectedPlatform(e.target.value)}
|
||||||
title="Filter by auction platform"
|
className="px-4 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||||
className="px-4 py-3 bg-background-secondary/50 border border-border rounded-xl
|
text-sm text-foreground cursor-pointer focus:outline-none focus:border-accent/50"
|
||||||
text-body text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent cursor-pointer transition-all"
|
|
||||||
>
|
>
|
||||||
{PLATFORMS.map(p => (
|
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
|
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Max bid"
|
placeholder="Max bid"
|
||||||
title="Filter auctions under this bid amount"
|
|
||||||
value={maxBid}
|
value={maxBid}
|
||||||
onChange={(e) => setMaxBid(e.target.value)}
|
onChange={(e) => setMaxBid(e.target.value)}
|
||||||
className="w-32 pl-11 pr-4 py-3 bg-background-secondary/50 border border-border rounded-xl
|
className="w-32 pl-10 pr-4 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||||
text-body text-foreground placeholder:text-foreground-subtle
|
text-sm text-foreground placeholder:text-foreground-subtle
|
||||||
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all"
|
focus:outline-none focus:border-accent/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Table */}
|
||||||
{loading ? (
|
<PremiumTable
|
||||||
<div className="flex items-center justify-center py-20">
|
data={sortedAuctions}
|
||||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
keyExtractor={(a) => `${a.domain}-${a.platform}`}
|
||||||
</div>
|
loading={loading}
|
||||||
) : activeTab === 'opportunities' && !isAuthenticated ? (
|
sortBy={sortBy}
|
||||||
<div className="text-center py-20 border border-dashed border-border rounded-2xl bg-background-secondary/20">
|
sortDirection={sortDirection}
|
||||||
<div className="w-14 h-14 bg-accent/10 rounded-2xl flex items-center justify-center mx-auto mb-5">
|
onSort={(key) => handleSort(key as SortField)}
|
||||||
<Target className="w-7 h-7 text-accent" />
|
emptyIcon={<Gavel className="w-12 h-12 text-foreground-subtle" />}
|
||||||
</div>
|
emptyTitle={searchQuery ? `No auctions matching "${searchQuery}"` : "No auctions found"}
|
||||||
<h3 className="text-body-lg font-medium text-foreground mb-2">Unlock Smart Opportunities</h3>
|
emptyDescription="Try adjusting your filters or check back later"
|
||||||
<p className="text-body-sm text-foreground-muted max-w-md mx-auto mb-6">
|
columns={[
|
||||||
Our algorithm analyzes ending times, bid activity, and price points to find the best opportunities.
|
{
|
||||||
</p>
|
key: 'domain',
|
||||||
<Link
|
header: 'Domain',
|
||||||
href="/register"
|
sortable: true,
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background text-ui font-medium rounded-xl
|
render: (a) => (
|
||||||
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
|
<div>
|
||||||
>
|
|
||||||
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
|
<a
|
||||||
href={auction.affiliate_url}
|
href={a.affiliate_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title={`Go to ${auction.platform} to bid on ${auction.domain}`}
|
className="font-mono font-medium text-foreground hover:text-accent transition-colors"
|
||||||
className="font-mono text-body-sm sm:text-body font-medium text-foreground hover:text-accent transition-colors"
|
|
||||||
>
|
>
|
||||||
{auction.domain}
|
{a.domain}
|
||||||
</a>
|
</a>
|
||||||
<div className="flex items-center gap-2 text-body-xs text-foreground-subtle lg:hidden">
|
<div className="flex items-center gap-2 mt-1 lg:hidden">
|
||||||
<span className={clsx("text-ui-xs px-1.5 py-0.5 rounded", getPlatformBadgeClass(auction.platform))}>
|
<PlatformBadge platform={a.platform} />
|
||||||
{auction.platform}
|
{a.age_years && <span className="text-xs text-foreground-subtle">{a.age_years}y</span>}
|
||||||
</span>
|
|
||||||
{auction.age_years && (
|
|
||||||
<span title={`Domain age: ${auction.age_years} years`}>{auction.age_years}y</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
),
|
||||||
|
},
|
||||||
{/* Platform */}
|
{
|
||||||
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell">
|
key: 'platform',
|
||||||
<div className="flex flex-col gap-1">
|
header: 'Platform',
|
||||||
<span
|
hideOnMobile: true,
|
||||||
className={clsx("text-ui-sm px-2 py-0.5 rounded-full w-fit", getPlatformBadgeClass(auction.platform))}
|
render: (a) => (
|
||||||
title={`${auction.platform} - Click Bid to go to auction`}
|
<div className="space-y-1">
|
||||||
>
|
<PlatformBadge platform={a.platform} />
|
||||||
{auction.platform}
|
{a.age_years && (
|
||||||
</span>
|
<span className="text-xs text-foreground-subtle flex items-center gap-1">
|
||||||
{auction.age_years && (
|
<Clock className="w-3 h-3" /> {a.age_years}y
|
||||||
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
),
|
||||||
|
},
|
||||||
{/* Current Bid */}
|
{
|
||||||
<td className="px-4 sm:px-6 py-4 text-right">
|
key: 'bid_asc',
|
||||||
<span
|
header: 'Bid',
|
||||||
className="text-body-sm font-medium text-foreground"
|
sortable: true,
|
||||||
title={`Current highest bid: ${formatCurrency(auction.current_bid)}`}
|
align: 'right',
|
||||||
>
|
render: (a) => (
|
||||||
{formatCurrency(auction.current_bid)}
|
<div>
|
||||||
</span>
|
<span className="font-medium text-foreground">{formatCurrency(a.current_bid)}</span>
|
||||||
{auction.buy_now_price && (
|
{a.buy_now_price && (
|
||||||
<p className="text-ui-xs text-accent" title={`Buy Now for ${formatCurrency(auction.buy_now_price)}`}>
|
<p className="text-xs text-accent">Buy: {formatCurrency(a.buy_now_price)}</p>
|
||||||
Buy: {formatCurrency(auction.buy_now_price)}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
|
),
|
||||||
{/* Bids */}
|
},
|
||||||
<td className="px-4 sm:px-6 py-4 text-right hidden sm:table-cell">
|
{
|
||||||
<span
|
key: 'bids',
|
||||||
className={clsx(
|
header: 'Bids',
|
||||||
"text-body-sm font-medium inline-flex items-center gap-1",
|
sortable: true,
|
||||||
auction.num_bids >= 20 ? "text-accent" :
|
align: 'right',
|
||||||
auction.num_bids >= 10 ? "text-warning" :
|
hideOnMobile: true,
|
||||||
"text-foreground-muted"
|
render: (a) => (
|
||||||
)}
|
<span className={clsx(
|
||||||
title={`${auction.num_bids} bids - ${auction.num_bids >= 20 ? 'High competition!' : auction.num_bids >= 10 ? 'Moderate interest' : 'Low competition'}`}
|
"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"
|
||||||
{auction.num_bids}
|
)}>
|
||||||
{auction.num_bids >= 20 && <Flame className="w-3 h-3" />}
|
{a.num_bids}
|
||||||
|
{a.num_bids >= 20 && <Flame className="w-3 h-3" />}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
),
|
||||||
|
},
|
||||||
{/* Time Left */}
|
{
|
||||||
<td className="px-4 sm:px-6 py-4 text-right hidden md:table-cell">
|
key: 'ending',
|
||||||
<span
|
header: 'Time Left',
|
||||||
className={clsx("text-body-sm font-medium", getTimeColor(auction.time_remaining))}
|
sortable: true,
|
||||||
title={`Auction ends: ${new Date(auction.end_time).toLocaleString()}`}
|
align: 'right',
|
||||||
>
|
hideOnMobile: true,
|
||||||
{auction.time_remaining}
|
render: (a) => (
|
||||||
|
<span className={clsx("font-medium", getTimeColor(a.time_remaining))}>
|
||||||
|
{a.time_remaining}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
),
|
||||||
|
},
|
||||||
{/* Score (opportunities only) */}
|
...(activeTab === 'opportunities' ? [{
|
||||||
{activeTab === 'opportunities' && oppData && (
|
key: 'score',
|
||||||
<td className="px-4 sm:px-6 py-4 text-center">
|
header: 'Score',
|
||||||
<span
|
align: 'center' as const,
|
||||||
className="inline-flex items-center justify-center w-9 h-9 bg-accent/10 text-accent font-bold rounded-lg text-body-sm"
|
render: (a: Auction) => {
|
||||||
title={`Score: ${oppData.opportunity_score}${oppData.reasoning ? ' - ' + oppData.reasoning : ''}`}
|
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}
|
{oppData.opportunity_score}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
)
|
||||||
)}
|
},
|
||||||
|
}] : []),
|
||||||
{/* Action */}
|
{
|
||||||
<td className="px-4 sm:px-6 py-4 text-right">
|
key: 'action',
|
||||||
|
header: '',
|
||||||
|
align: 'right',
|
||||||
|
render: (a) => (
|
||||||
<a
|
<a
|
||||||
href={auction.affiliate_url}
|
href={a.affiliate_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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-xs font-medium rounded-lg
|
||||||
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"
|
hover:bg-foreground/90 transition-all opacity-70 group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
Bid
|
Bid <ExternalLink className="w-3 h-3" />
|
||||||
<ExternalLink className="w-3.5 h-3.5" />
|
|
||||||
</a>
|
</a>
|
||||||
</td>
|
),
|
||||||
</tr>
|
},
|
||||||
)
|
]}
|
||||||
})
|
/>
|
||||||
)}
|
</PageContainer>
|
||||||
</tbody>
|
</CommandCenterLayout>
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
|
import { PremiumTable, Badge, StatCard, PageContainer } from '@/components/PremiumTable'
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@ -12,9 +13,10 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Globe,
|
Globe,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
ExternalLink,
|
|
||||||
BarChart3,
|
|
||||||
DollarSign,
|
DollarSign,
|
||||||
|
BarChart3,
|
||||||
|
RefreshCw,
|
||||||
|
Bell,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@ -35,6 +37,7 @@ export default function IntelligencePage() {
|
|||||||
|
|
||||||
const [tldData, setTldData] = useState<TLDData[]>([])
|
const [tldData, setTldData] = useState<TLDData[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [sortBy, setSortBy] = useState<'popularity' | 'price_asc' | 'price_desc' | 'change'>('popularity')
|
const [sortBy, setSortBy] = useState<'popularity' | 'price_asc' | 'price_desc' | 'change'>('popularity')
|
||||||
const [page, setPage] = useState(0)
|
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
|
// Filter by search
|
||||||
const filteredData = tldData.filter(tld =>
|
const filteredData = tldData.filter(tld =>
|
||||||
tld.tld.toLowerCase().includes(searchQuery.toLowerCase())
|
tld.tld.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
@ -72,81 +81,56 @@ export default function IntelligencePage() {
|
|||||||
return <TrendingDown className="w-4 h-4 text-accent" />
|
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 (
|
return (
|
||||||
<CommandCenterLayout
|
<CommandCenterLayout
|
||||||
title="TLD Intelligence"
|
title="TLD Intelligence"
|
||||||
subtitle={`Real-time pricing data for ${total}+ TLDs`}
|
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"
|
||||||
>
|
>
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||||
|
<span className="hidden sm:inline">Refresh</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PageContainer>
|
||||||
{/* Stats Overview */}
|
{/* Stats Overview */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<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">
|
<StatCard title="TLDs Tracked" value={total} subtitle="updated daily" icon={Globe} accent />
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<StatCard title="Lowest Price" value={`$${lowestPrice === Infinity ? '0.99' : lowestPrice.toFixed(2)}`} icon={DollarSign} />
|
||||||
<div className="w-10 h-10 bg-accent/10 rounded-xl flex items-center justify-center">
|
<StatCard title="Hottest TLD" value={`.${hottestTld}`} subtitle="rising prices" icon={TrendingUp} />
|
||||||
<Globe className="w-5 h-5 text-accent" />
|
<StatCard title="Update Freq" value="24h" subtitle="automatic" icon={BarChart3} />
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1 max-w-md">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Search TLDs..."
|
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
|
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>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value as any)}
|
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
|
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="popularity">By Popularity</option>
|
||||||
<option value="price_asc">Price: Low to High</option>
|
<option value="price_asc">Price: Low to High</option>
|
||||||
@ -158,49 +142,44 @@ export default function IntelligencePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TLD Table */}
|
{/* TLD Table */}
|
||||||
{loading ? (
|
<PremiumTable
|
||||||
<div className="space-y-3">
|
data={filteredData}
|
||||||
{[...Array(10)].map((_, i) => (
|
keyExtractor={(tld) => tld.tld}
|
||||||
<div key={i} className="h-16 bg-background-secondary/50 border border-border rounded-xl animate-pulse" />
|
loading={loading}
|
||||||
))}
|
onRowClick={(tld) => window.location.href = `/tld-pricing/${tld.tld}`}
|
||||||
</div>
|
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />}
|
||||||
) : (
|
emptyTitle="No TLDs found"
|
||||||
<div className="overflow-hidden border border-border rounded-xl">
|
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
|
||||||
{/* Table Header */}
|
columns={[
|
||||||
<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>
|
key: 'tld',
|
||||||
<div className="col-span-2">Min Price</div>
|
header: 'TLD',
|
||||||
<div className="col-span-2">Avg Price</div>
|
render: (tld) => (
|
||||||
<div className="col-span-2">Change</div>
|
<span className="font-mono text-xl font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||||
<div className="col-span-3">Cheapest Registrar</div>
|
.{tld.tld}
|
||||||
<div className="col-span-1"></div>
|
</span>
|
||||||
</div>
|
),
|
||||||
|
},
|
||||||
{/* Table Rows */}
|
{
|
||||||
<div className="divide-y divide-border">
|
key: 'min_price',
|
||||||
{filteredData.map((tld) => (
|
header: 'Min Price',
|
||||||
<Link
|
render: (tld) => (
|
||||||
key={tld.tld}
|
<span className="font-medium text-foreground">${tld.min_price.toFixed(2)}</span>
|
||||||
href={`/tld-pricing/${tld.tld}`}
|
),
|
||||||
className="block lg:grid lg:grid-cols-12 gap-4 p-4 hover:bg-foreground/5 transition-colors"
|
},
|
||||||
>
|
{
|
||||||
{/* TLD */}
|
key: 'avg_price',
|
||||||
<div className="col-span-2 flex items-center gap-3 mb-3 lg:mb-0">
|
header: 'Avg Price',
|
||||||
<span className="font-mono text-xl font-semibold text-foreground">.{tld.tld}</span>
|
hideOnMobile: true,
|
||||||
</div>
|
render: (tld) => (
|
||||||
|
|
||||||
{/* 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>
|
<span className="text-foreground-muted">${tld.avg_price.toFixed(2)}</span>
|
||||||
</div>
|
),
|
||||||
|
},
|
||||||
{/* Change */}
|
{
|
||||||
<div className="col-span-2 flex items-center gap-2">
|
key: 'change',
|
||||||
|
header: '7d Change',
|
||||||
|
render: (tld) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{getTrendIcon(tld.price_change_7d)}
|
{getTrendIcon(tld.price_change_7d)}
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"font-medium",
|
"font-medium",
|
||||||
@ -210,30 +189,44 @@ export default function IntelligencePage() {
|
|||||||
{(tld.price_change_7d || 0) > 0 ? '+' : ''}{(tld.price_change_7d || 0).toFixed(1)}%
|
{(tld.price_change_7d || 0) > 0 ? '+' : ''}{(tld.price_change_7d || 0).toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
{/* Registrar */}
|
},
|
||||||
<div className="col-span-3 flex items-center">
|
{
|
||||||
<span className="text-foreground-muted truncate">{tld.cheapest_registrar}</span>
|
key: 'registrar',
|
||||||
</div>
|
header: 'Cheapest At',
|
||||||
|
hideOnMobile: true,
|
||||||
{/* Arrow */}
|
render: (tld) => (
|
||||||
<div className="col-span-1 flex items-center justify-end">
|
<span className="text-foreground-muted truncate max-w-[150px] block">{tld.cheapest_registrar}</span>
|
||||||
<ChevronRight className="w-5 h-5 text-foreground-subtle" />
|
),
|
||||||
</div>
|
},
|
||||||
|
{
|
||||||
|
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>
|
</Link>
|
||||||
))}
|
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
),
|
||||||
)}
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{total > 50 && (
|
{total > 50 && (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage(Math.max(0, page - 1))}
|
onClick={() => setPage(Math.max(0, page - 1))}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
className="px-4 py-2 text-sm text-foreground-muted hover:text-foreground
|
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
@ -243,15 +236,14 @@ export default function IntelligencePage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage(page + 1)}
|
onClick={() => setPage(page + 1)}
|
||||||
disabled={(page + 1) * 50 >= total}
|
disabled={(page + 1) * 50 >= total}
|
||||||
className="px-4 py-2 text-sm text-foreground-muted hover:text-foreground
|
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PageContainer>
|
||||||
</CommandCenterLayout>
|
</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 { useRouter } from 'next/navigation'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { Sidebar } from './Sidebar'
|
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 Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
@ -80,6 +81,8 @@ export function CommandCenterLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<KeyboardShortcutsProvider>
|
||||||
|
<UserShortcutsWrapper />
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
{/* Background Effects */}
|
{/* Background Effects */}
|
||||||
<div className="fixed inset-0 pointer-events-none">
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
@ -207,6 +210,17 @@ export function CommandCenterLayout({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Custom Actions */}
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
@ -258,6 +272,7 @@ export function CommandCenterLayout({
|
|||||||
{/* Keyboard shortcut for search */}
|
{/* Keyboard shortcut for search */}
|
||||||
<KeyboardShortcut onTrigger={() => setSearchOpen(true)} keys={['Meta', 'k']} />
|
<KeyboardShortcut onTrigger={() => setSearchOpen(true)} keys={['Meta', 'k']} />
|
||||||
</div>
|
</div>
|
||||||
|
</KeyboardShortcutsProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,3 +291,9 @@ function KeyboardShortcut({ onTrigger, keys }: { onTrigger: () => void, keys: st
|
|||||||
|
|
||||||
return null
|
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