cleanup: Remove old Command Center files and fix all references

- Removed old folders: dashboard, pricing, auctions, marketplace, portfolio, alerts, seo
- Removed CommandCenterLayout.tsx (replaced by TerminalLayout)
- Fixed all internal links to use new terminal routes
- Updated keyboard shortcuts for new module names
- Fixed welcome page next steps
- Fixed landing page feature links
- Fixed radar page stat cards and links
This commit is contained in:
2025-12-10 21:59:56 +01:00
parent 1b1aea6f07
commit 5b200a21bc
9 changed files with 12 additions and 2948 deletions

View File

@ -489,7 +489,7 @@ export default function HomePage() {
</li>
</ul>
<Link
href="/terminal/alerts"
href="/terminal/watchlist"
className="inline-flex items-center gap-2 px-4 py-2 border border-border text-foreground text-sm font-medium rounded-lg hover:border-accent hover:text-accent transition-all"
>
Set Up
@ -536,7 +536,7 @@ export default function HomePage() {
</li>
</ul>
<Link
href="/terminal/portfolio"
href="/terminal/watchlist"
className="inline-flex items-center gap-2 px-4 py-2 border border-border text-foreground text-sm font-medium rounded-lg hover:border-accent hover:text-accent transition-all"
>
Manage

View File

@ -1,597 +0,0 @@
'use client'
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { TerminalLayout } from '@/components/TerminalLayout'
import { PageContainer, StatCard, Badge, ActionButton } from '@/components/PremiumTable'
import {
Plus,
Bell,
Target,
Zap,
Loader2,
Trash2,
CheckCircle,
AlertCircle,
X,
Play,
Pause,
Mail,
Settings,
TestTube,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import clsx from 'clsx'
interface SniperAlert {
id: number
name: string
description: string | null
tlds: string | null
keywords: string | null
exclude_keywords: string | null
max_length: number | null
min_length: number | null
max_price: number | null
min_price: number | null
max_bids: number | null
ending_within_hours: number | null
platforms: string | null
no_numbers: boolean
no_hyphens: boolean
exclude_chars: string | null
notify_email: boolean
notify_sms: boolean
is_active: boolean
matches_count: number
notifications_sent: number
last_matched_at: string | null
created_at: string
}
interface TestResult {
alert_name: string
auctions_checked: number
matches_found: number
matches: Array<{
domain: string
platform: string
current_bid: number
num_bids: number
end_time: string
}>
message: string
}
export default function SniperAlertsPage() {
const { subscription } = useStore()
const [alerts, setAlerts] = useState<SniperAlert[]>([])
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [creating, setCreating] = useState(false)
const [testing, setTesting] = useState<number | null>(null)
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [expandedAlert, setExpandedAlert] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
// Create form
const [newAlert, setNewAlert] = useState({
name: '',
description: '',
tlds: '',
keywords: '',
exclude_keywords: '',
max_length: '',
min_length: '',
max_price: '',
min_price: '',
max_bids: '',
no_numbers: false,
no_hyphens: false,
exclude_chars: '',
notify_email: true,
})
const loadAlerts = useCallback(async () => {
setLoading(true)
try {
const data = await api.request<SniperAlert[]>('/sniper-alerts')
setAlerts(data)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadAlerts()
}, [loadAlerts])
const handleCreate = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
setCreating(true)
setError(null)
try {
await api.request('/sniper-alerts', {
method: 'POST',
body: JSON.stringify({
name: newAlert.name,
description: newAlert.description || null,
tlds: newAlert.tlds || null,
keywords: newAlert.keywords || null,
exclude_keywords: newAlert.exclude_keywords || null,
max_length: newAlert.max_length ? parseInt(newAlert.max_length) : null,
min_length: newAlert.min_length ? parseInt(newAlert.min_length) : null,
max_price: newAlert.max_price ? parseFloat(newAlert.max_price) : null,
min_price: newAlert.min_price ? parseFloat(newAlert.min_price) : null,
max_bids: newAlert.max_bids ? parseInt(newAlert.max_bids) : null,
no_numbers: newAlert.no_numbers,
no_hyphens: newAlert.no_hyphens,
exclude_chars: newAlert.exclude_chars || null,
notify_email: newAlert.notify_email,
}),
})
setSuccess('Sniper Alert created!')
setShowCreateModal(false)
setNewAlert({
name: '', description: '', tlds: '', keywords: '', exclude_keywords: '',
max_length: '', min_length: '', max_price: '', min_price: '', max_bids: '',
no_numbers: false, no_hyphens: false, exclude_chars: '', notify_email: true,
})
loadAlerts()
} catch (err: any) {
setError(err.message)
} finally {
setCreating(false)
}
}, [newAlert, loadAlerts])
const handleToggle = useCallback(async (alert: SniperAlert) => {
try {
await api.request(`/sniper-alerts/${alert.id}`, {
method: 'PUT',
body: JSON.stringify({ is_active: !alert.is_active }),
})
loadAlerts()
} catch (err: any) {
setError(err.message)
}
}, [loadAlerts])
const handleDelete = useCallback(async (alert: SniperAlert) => {
if (!confirm(`Delete alert "${alert.name}"?`)) return
try {
await api.request(`/sniper-alerts/${alert.id}`, { method: 'DELETE' })
setSuccess('Alert deleted')
loadAlerts()
} catch (err: any) {
setError(err.message)
}
}, [loadAlerts])
const handleTest = useCallback(async (alert: SniperAlert) => {
setTesting(alert.id)
setTestResult(null)
try {
const result = await api.request<TestResult>(`/sniper-alerts/${alert.id}/test`, {
method: 'POST',
})
setTestResult(result)
setExpandedAlert(alert.id)
} catch (err: any) {
setError(err.message)
} finally {
setTesting(null)
}
}, [])
// Memoized stats
const stats = useMemo(() => ({
activeAlerts: alerts.filter(a => a.is_active).length,
totalMatches: alerts.reduce((sum, a) => sum + a.matches_count, 0),
notificationsSent: alerts.reduce((sum, a) => sum + a.notifications_sent, 0),
}), [alerts])
const tier = subscription?.tier || 'scout'
const limits = { scout: 2, trader: 10, tycoon: 50 }
const maxAlerts = limits[tier as keyof typeof limits] || 2
return (
<TerminalLayout
title="Sniper Alerts"
subtitle={`Hyper-personalized auction notifications (${alerts.length}/${maxAlerts})`}
actions={
<ActionButton onClick={() => setShowCreateModal(true)} disabled={alerts.length >= maxAlerts} icon={Plus}>
New Alert
</ActionButton>
}
>
<PageContainer>
{/* Messages */}
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-400" />
<p className="text-sm text-red-400 flex-1">{error}</p>
<button onClick={() => setError(null)}><X className="w-4 h-4 text-red-400" /></button>
</div>
)}
{success && (
<div className="p-4 bg-accent/10 border border-accent/20 rounded-xl flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-accent" />
<p className="text-sm text-accent flex-1">{success}</p>
<button onClick={() => setSuccess(null)}><X className="w-4 h-4 text-accent" /></button>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Active Alerts" value={stats.activeAlerts} icon={Bell} />
<StatCard title="Total Matches" value={stats.totalMatches} icon={Target} />
<StatCard title="Notifications Sent" value={stats.notificationsSent} icon={Zap} />
<StatCard title="Alert Slots" value={`${alerts.length}/${maxAlerts}`} icon={Settings} />
</div>
{/* Alerts List */}
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
) : alerts.length === 0 ? (
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-2xl">
<Target className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
<h2 className="text-xl font-medium text-foreground mb-2">No Sniper Alerts</h2>
<p className="text-foreground-muted mb-6 max-w-md mx-auto">
Create alerts to get notified when domains matching your criteria appear in auctions.
</p>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
>
<Plus className="w-5 h-5" />
Create Alert
</button>
</div>
) : (
<div className="space-y-4">
{alerts.map((alert) => (
<div
key={alert.id}
className="bg-background-secondary/30 border border-border rounded-2xl overflow-hidden transition-all hover:border-border-hover"
>
{/* Header */}
<div className="p-5">
<div className="flex flex-wrap items-start gap-4">
<div className="flex-1 min-w-[200px]">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-medium text-foreground">{alert.name}</h3>
<Badge variant={alert.is_active ? 'success' : 'default'}>
{alert.is_active ? 'Active' : 'Paused'}
</Badge>
</div>
{alert.description && (
<p className="text-sm text-foreground-muted">{alert.description}</p>
)}
</div>
{/* Stats */}
<div className="flex items-center gap-6 text-sm">
<div className="text-center">
<p className="text-lg font-semibold text-foreground">{alert.matches_count}</p>
<p className="text-xs text-foreground-muted">Matches</p>
</div>
<div className="text-center">
<p className="text-lg font-semibold text-foreground">{alert.notifications_sent}</p>
<p className="text-xs text-foreground-muted">Notified</p>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => handleTest(alert)}
disabled={testing === alert.id}
className="flex items-center gap-1.5 px-3 py-2 bg-foreground/5 text-foreground-muted text-sm font-medium rounded-lg hover:bg-foreground/10 transition-all"
>
{testing === alert.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<TestTube className="w-4 h-4" />
)}
Test
</button>
<button
onClick={() => handleToggle(alert)}
className={clsx(
"flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-all",
alert.is_active
? "bg-amber-500/10 text-amber-400 hover:bg-amber-500/20"
: "bg-accent/10 text-accent hover:bg-accent/20"
)}
>
{alert.is_active ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{alert.is_active ? 'Pause' : 'Activate'}
</button>
<button
onClick={() => handleDelete(alert)}
className="p-2 text-foreground-subtle hover:text-red-400 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
<button
onClick={() => setExpandedAlert(expandedAlert === alert.id ? null : alert.id)}
className="p-2 text-foreground-subtle hover:text-foreground transition-colors"
>
{expandedAlert === alert.id ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* Filter Summary */}
<div className="mt-4 flex flex-wrap gap-2">
{alert.tlds && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
TLDs: {alert.tlds}
</span>
)}
{alert.max_length && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
Max {alert.max_length} chars
</span>
)}
{alert.max_price && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
Max ${alert.max_price}
</span>
)}
{alert.no_numbers && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
No numbers
</span>
)}
{alert.no_hyphens && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
No hyphens
</span>
)}
{alert.notify_email && (
<span className="px-2 py-1 bg-accent/10 text-accent text-xs rounded flex items-center gap-1">
<Mail className="w-3 h-3" /> Email
</span>
)}
</div>
</div>
{/* Test Results */}
{expandedAlert === alert.id && testResult && testResult.alert_name === alert.name && (
<div className="px-5 pb-5">
<div className="p-4 bg-background rounded-xl border border-border">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium text-foreground">Test Results</p>
<p className="text-xs text-foreground-muted">
Checked {testResult.auctions_checked} auctions
</p>
</div>
{testResult.matches_found === 0 ? (
<p className="text-sm text-foreground-muted">{testResult.message}</p>
) : (
<div className="space-y-2">
<p className="text-sm text-accent">
Found {testResult.matches_found} matching domains!
</p>
<div className="max-h-48 overflow-y-auto space-y-1">
{testResult.matches.map((match, idx) => (
<div key={idx} className="flex items-center justify-between text-sm py-1">
<span className="font-mono text-foreground">{match.domain}</span>
<span className="text-foreground-muted">${match.current_bid}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</PageContainer>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm overflow-y-auto">
<div className="w-full max-w-lg bg-background-secondary border border-border rounded-2xl p-6 my-8">
<h2 className="text-xl font-semibold text-foreground mb-2">Create Sniper Alert</h2>
<p className="text-sm text-foreground-muted mb-6">
Get notified when domains matching your criteria appear in auctions.
</p>
<form onSubmit={handleCreate} className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
<div>
<label className="block text-sm text-foreground-muted mb-1">Alert Name *</label>
<input
type="text"
required
value={newAlert.name}
onChange={(e) => setNewAlert({ ...newAlert, name: e.target.value })}
placeholder="4-letter .com without numbers"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Description</label>
<input
type="text"
value={newAlert.description}
onChange={(e) => setNewAlert({ ...newAlert, description: e.target.value })}
placeholder="Optional description"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1">TLDs (comma-separated)</label>
<input
type="text"
value={newAlert.tlds}
onChange={(e) => setNewAlert({ ...newAlert, tlds: e.target.value })}
placeholder="com,io,ai"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Keywords (must contain)</label>
<input
type="text"
value={newAlert.keywords}
onChange={(e) => setNewAlert({ ...newAlert, keywords: e.target.value })}
placeholder="ai,tech,crypto"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1">Min Length</label>
<input
type="number"
min="1"
max="63"
value={newAlert.min_length}
onChange={(e) => setNewAlert({ ...newAlert, min_length: e.target.value })}
placeholder="3"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Max Length</label>
<input
type="number"
min="1"
max="63"
value={newAlert.max_length}
onChange={(e) => setNewAlert({ ...newAlert, max_length: e.target.value })}
placeholder="6"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1">Max Price ($)</label>
<input
type="number"
min="0"
value={newAlert.max_price}
onChange={(e) => setNewAlert({ ...newAlert, max_price: e.target.value })}
placeholder="500"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Max Bids (low competition)</label>
<input
type="number"
min="0"
value={newAlert.max_bids}
onChange={(e) => setNewAlert({ ...newAlert, max_bids: e.target.value })}
placeholder="5"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Exclude Characters</label>
<input
type="text"
value={newAlert.exclude_chars}
onChange={(e) => setNewAlert({ ...newAlert, exclude_chars: e.target.value })}
placeholder="q,x,z"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newAlert.no_numbers}
onChange={(e) => setNewAlert({ ...newAlert, no_numbers: e.target.checked })}
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
/>
<span className="text-sm text-foreground">No numbers</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newAlert.no_hyphens}
onChange={(e) => setNewAlert({ ...newAlert, no_hyphens: e.target.checked })}
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
/>
<span className="text-sm text-foreground">No hyphens</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newAlert.notify_email}
onChange={(e) => setNewAlert({ ...newAlert, notify_email: e.target.checked })}
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
/>
<span className="text-sm text-foreground flex items-center gap-1">
<Mail className="w-4 h-4" /> Email alerts
</span>
</label>
</div>
</form>
<div className="flex gap-3 mt-6">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-3 border border-border text-foreground-muted rounded-xl hover:bg-foreground/5 transition-all"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={creating || !newAlert.name}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all disabled:opacity-50"
>
{creating ? <Loader2 className="w-5 h-5 animate-spin" /> : <Target className="w-5 h-5" />}
{creating ? 'Creating...' : 'Create Alert'}
</button>
</div>
</div>
</div>
)}
</TerminalLayout>
)
}

View File

@ -1,578 +0,0 @@
'use client'
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { TerminalLayout } from '@/components/TerminalLayout'
import {
PremiumTable,
Badge,
PlatformBadge,
StatCard,
PageContainer,
SearchInput,
TabBar,
FilterBar,
SelectDropdown,
ActionButton,
} from '@/components/PremiumTable'
import {
Clock,
ExternalLink,
Flame,
Timer,
Gavel,
DollarSign,
RefreshCw,
Target,
Loader2,
Sparkles,
Eye,
Zap,
Crown,
Plus,
Check,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
interface Auction {
domain: string
platform: string
platform_url: string
current_bid: number
currency: string
num_bids: number
end_time: string
time_remaining: string
buy_now_price: number | null
reserve_met: boolean | null
traffic: number | null
age_years: number | null
tld: string
affiliate_url: string
}
interface Opportunity {
auction: Auction
analysis: {
opportunity_score: number
urgency?: string
competition?: string
price_range?: string
recommendation: string
reasoning?: string
}
}
type TabType = 'all' | 'ending' | 'hot' | 'opportunities'
type SortField = 'ending' | 'bid_asc' | 'bid_desc' | 'bids' | 'score'
type FilterPreset = 'all' | 'no-trash' | 'short' | 'high-value' | 'low-competition'
const PLATFORMS = [
{ value: 'All', label: 'All Sources' },
{ value: 'GoDaddy', label: 'GoDaddy' },
{ value: 'Sedo', label: 'Sedo' },
{ value: 'NameJet', label: 'NameJet' },
{ value: 'DropCatch', label: 'DropCatch' },
]
const FILTER_PRESETS: { id: FilterPreset, label: string, icon: typeof Gavel, description: string, proOnly?: boolean }[] = [
{ id: 'all', label: 'All', icon: Gavel, description: 'Show all auctions' },
{ id: 'no-trash', label: 'No Trash', icon: Sparkles, description: 'Clean domains only (no spam)', proOnly: true },
{ id: 'short', label: 'Short', icon: Zap, description: '4-letter domains or less' },
{ id: 'high-value', label: 'High Value', icon: Crown, description: 'Premium TLDs with high activity', proOnly: true },
{ id: 'low-competition', label: 'Low Competition', icon: Target, description: 'Under 5 bids' },
]
const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'net', 'org', 'app', 'dev']
// Pure functions (no hooks needed)
function isCleanDomain(auction: Auction): boolean {
const name = auction.domain.split('.')[0]
if (name.includes('-')) return false
if (name.length > 4 && /\d/.test(name)) return false
if (name.length > 12) return false
if (!PREMIUM_TLDS.includes(auction.tld)) return false
return true
}
function calculateDealScore(auction: Auction): number {
let score = 50
const name = auction.domain.split('.')[0]
if (name.length <= 4) score += 25
else if (name.length <= 6) score += 15
else if (name.length <= 8) score += 5
if (['com', 'io', 'ai'].includes(auction.tld)) score += 15
else if (['co', 'net', 'org'].includes(auction.tld)) score += 5
if (auction.age_years && auction.age_years > 10) score += 15
else if (auction.age_years && auction.age_years > 5) score += 10
if (auction.num_bids >= 20) score += 10
else if (auction.num_bids >= 10) score += 5
if (isCleanDomain(auction)) score += 10
return Math.min(score, 100)
}
function getTimeColor(timeRemaining: string): string {
if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) return 'text-red-400'
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) return 'text-amber-400'
return 'text-foreground-muted'
}
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value)
}
export default function AuctionsPage() {
const { isAuthenticated, subscription } = useStore()
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
const [hotAuctions, setHotAuctions] = useState<Auction[]>([])
const [opportunities, setOpportunities] = useState<Opportunity[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [activeTab, setActiveTab] = useState<TabType>('all')
const [sortBy, setSortBy] = useState<SortField>('ending')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
// Filters
const [searchQuery, setSearchQuery] = useState('')
const [selectedPlatform, setSelectedPlatform] = useState('All')
const [maxBid, setMaxBid] = useState('')
const [filterPreset, setFilterPreset] = useState<FilterPreset>('all')
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
const isPaidUser = subscription?.tier === 'trader' || subscription?.tier === 'tycoon'
// Data loading
const loadData = useCallback(async () => {
setLoading(true)
try {
const [auctionsData, hotData, endingData] = await Promise.all([
api.getAuctions(),
api.getHotAuctions(50),
api.getEndingSoonAuctions(24, 50),
])
setAllAuctions(auctionsData.auctions || [])
setHotAuctions(hotData || [])
setEndingSoon(endingData || [])
} catch (error) {
console.error('Failed to load auction data:', error)
} finally {
setLoading(false)
}
}, [])
const loadOpportunities = useCallback(async () => {
try {
const oppData = await api.getAuctionOpportunities()
setOpportunities(oppData.opportunities || [])
} catch (e) {
console.error('Failed to load opportunities:', e)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
useEffect(() => {
if (isAuthenticated && opportunities.length === 0) {
loadOpportunities()
}
}, [isAuthenticated, opportunities.length, loadOpportunities])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
await loadData()
if (isAuthenticated) await loadOpportunities()
setRefreshing(false)
}, [loadData, loadOpportunities, isAuthenticated])
const handleTrackDomain = useCallback(async (domain: string) => {
if (trackedDomains.has(domain)) return
setTrackingInProgress(domain)
try {
await api.addDomain(domain)
setTrackedDomains(prev => new Set([...Array.from(prev), domain]))
} catch (error) {
console.error('Failed to track domain:', error)
} finally {
setTrackingInProgress(null)
}
}, [trackedDomains])
const handleSort = useCallback((field: string) => {
const f = field as SortField
if (sortBy === f) {
setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
} else {
setSortBy(f)
setSortDirection('asc')
}
}, [sortBy])
// Memoized tabs
const tabs = useMemo(() => [
{ id: 'all', label: 'All', icon: Gavel, count: allAuctions.length },
{ id: 'ending', label: 'Ending Soon', icon: Timer, count: endingSoon.length, color: 'warning' as const },
{ id: 'hot', label: 'Hot', icon: Flame, count: hotAuctions.length },
{ id: 'opportunities', label: 'Opportunities', icon: Target, count: opportunities.length },
], [allAuctions.length, endingSoon.length, hotAuctions.length, opportunities.length])
// Filter and sort auctions
const sortedAuctions = useMemo(() => {
// Get base auctions for current tab
let auctions: Auction[] = []
switch (activeTab) {
case 'ending': auctions = [...endingSoon]; break
case 'hot': auctions = [...hotAuctions]; break
case 'opportunities': auctions = opportunities.map(o => o.auction); break
default: auctions = [...allAuctions]
}
// Apply preset filter
const baseFilter = filterPreset === 'all' && isPaidUser ? 'no-trash' : filterPreset
switch (baseFilter) {
case 'no-trash': auctions = auctions.filter(isCleanDomain); break
case 'short': auctions = auctions.filter(a => a.domain.split('.')[0].length <= 4); break
case 'high-value': auctions = auctions.filter(a =>
PREMIUM_TLDS.slice(0, 3).includes(a.tld) && a.num_bids >= 5 && calculateDealScore(a) >= 70
); break
case 'low-competition': auctions = auctions.filter(a => a.num_bids < 5); break
}
// Apply search
if (searchQuery) {
const q = searchQuery.toLowerCase()
auctions = auctions.filter(a => a.domain.toLowerCase().includes(q))
}
// Apply platform filter
if (selectedPlatform !== 'All') {
auctions = auctions.filter(a => a.platform === selectedPlatform)
}
// Apply max bid
if (maxBid) {
const max = parseFloat(maxBid)
auctions = auctions.filter(a => a.current_bid <= max)
}
// Sort (skip for opportunities - already sorted by score)
if (activeTab !== 'opportunities') {
const mult = sortDirection === 'asc' ? 1 : -1
auctions.sort((a, b) => {
switch (sortBy) {
case 'ending': return mult * (new Date(a.end_time).getTime() - new Date(b.end_time).getTime())
case 'bid_asc':
case 'bid_desc': return mult * (a.current_bid - b.current_bid)
case 'bids': return mult * (b.num_bids - a.num_bids)
case 'score': return mult * (calculateDealScore(b) - calculateDealScore(a))
default: return 0
}
})
}
return auctions
}, [activeTab, allAuctions, endingSoon, hotAuctions, opportunities, filterPreset, isPaidUser, searchQuery, selectedPlatform, maxBid, sortBy, sortDirection])
// Subtitle
const subtitle = useMemo(() => {
if (loading) return 'Loading live auctions...'
const total = allAuctions.length
if (total === 0) return 'No active auctions found'
return `${sortedAuctions.length.toLocaleString()} auctions ${sortedAuctions.length < total ? `(filtered from ${total.toLocaleString()})` : 'across 4 platforms'}`
}, [loading, allAuctions.length, sortedAuctions.length])
// Get opportunity data helper
const getOpportunityData = useCallback((domain: string) => {
if (activeTab !== 'opportunities') return null
return opportunities.find(o => o.auction.domain === domain)?.analysis
}, [activeTab, opportunities])
// Table columns - memoized
const columns = useMemo(() => [
{
key: 'domain',
header: 'Domain',
sortable: true,
render: (a: Auction) => (
<div>
<a
href={a.affiliate_url}
target="_blank"
rel="noopener noreferrer"
className="font-mono font-medium text-foreground hover:text-accent transition-colors"
>
{a.domain}
</a>
<div className="flex items-center gap-2 mt-1 lg:hidden">
<PlatformBadge platform={a.platform} />
{a.age_years && <span className="text-xs text-foreground-subtle">{a.age_years}y</span>}
</div>
</div>
),
},
{
key: 'platform',
header: 'Platform',
hideOnMobile: true,
render: (a: Auction) => (
<div className="space-y-1">
<PlatformBadge platform={a.platform} />
{a.age_years && (
<span className="text-xs text-foreground-subtle flex items-center gap-1">
<Clock className="w-3 h-3" /> {a.age_years}y
</span>
)}
</div>
),
},
{
key: 'bid_asc',
header: 'Bid',
sortable: true,
align: 'right' as const,
render: (a: Auction) => (
<div>
<span className="font-medium text-foreground">{formatCurrency(a.current_bid)}</span>
{a.buy_now_price && (
<p className="text-xs text-accent">Buy: {formatCurrency(a.buy_now_price)}</p>
)}
</div>
),
},
{
key: 'score',
header: 'Deal Score',
sortable: true,
align: 'center' as const,
hideOnMobile: true,
render: (a: Auction) => {
if (activeTab === 'opportunities') {
const oppData = getOpportunityData(a.domain)
if (oppData) {
return (
<span className="inline-flex items-center justify-center w-9 h-9 bg-accent/10 text-accent font-bold rounded-lg">
{oppData.opportunity_score}
</span>
)
}
}
if (!isPaidUser) {
return (
<Link
href="/pricing"
className="inline-flex items-center justify-center w-9 h-9 bg-foreground/5 text-foreground-subtle rounded-lg hover:bg-accent/10 hover:text-accent transition-all"
title="Upgrade to see Deal Score"
>
<Crown className="w-4 h-4" />
</Link>
)
}
const score = calculateDealScore(a)
return (
<div className="inline-flex flex-col items-center">
<span className={clsx(
"inline-flex items-center justify-center w-9 h-9 rounded-lg font-bold text-sm",
score >= 75 ? "bg-accent/20 text-accent" :
score >= 50 ? "bg-amber-500/20 text-amber-400" :
"bg-foreground/10 text-foreground-muted"
)}>
{score}
</span>
{score >= 75 && <span className="text-[10px] text-accent mt-0.5 font-medium">Undervalued</span>}
</div>
)
},
},
{
key: 'bids',
header: 'Bids',
sortable: true,
align: 'right' as const,
hideOnMobile: true,
render: (a: Auction) => (
<span className={clsx(
"font-medium flex items-center justify-end gap-1",
a.num_bids >= 20 ? "text-accent" : a.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted"
)}>
{a.num_bids}
{a.num_bids >= 20 && <Flame className="w-3 h-3" />}
</span>
),
},
{
key: 'ending',
header: 'Time Left',
sortable: true,
align: 'right' as const,
hideOnMobile: true,
render: (a: Auction) => (
<span className={clsx("font-medium", getTimeColor(a.time_remaining))}>
{a.time_remaining}
</span>
),
},
{
key: 'actions',
header: '',
align: 'right' as const,
render: (a: Auction) => (
<div className="flex items-center gap-2 justify-end">
<button
onClick={(e) => { e.preventDefault(); handleTrackDomain(a.domain) }}
disabled={trackedDomains.has(a.domain) || trackingInProgress === a.domain}
className={clsx(
"inline-flex items-center justify-center w-8 h-8 rounded-lg transition-all",
trackedDomains.has(a.domain)
? "bg-accent/20 text-accent cursor-default"
: "bg-foreground/5 text-foreground-subtle hover:bg-accent/10 hover:text-accent"
)}
title={trackedDomains.has(a.domain) ? 'Already tracked' : 'Add to Watchlist'}
>
{trackingInProgress === a.domain ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : trackedDomains.has(a.domain) ? (
<Check className="w-4 h-4" />
) : (
<Plus className="w-4 h-4" />
)}
</button>
<a
href={a.affiliate_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background text-xs font-medium rounded-lg hover:bg-foreground/90 transition-all"
>
Bid <ExternalLink className="w-3 h-3" />
</a>
</div>
),
},
], [activeTab, isPaidUser, trackedDomains, trackingInProgress, handleTrackDomain, getOpportunityData])
return (
<TerminalLayout
title="Auctions"
subtitle={subtitle}
actions={
<ActionButton onClick={handleRefresh} disabled={refreshing} variant="ghost" icon={refreshing ? Loader2 : RefreshCw}>
{refreshing ? '' : 'Refresh'}
</ActionButton>
}
>
<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} />
<StatCard title="Hot Auctions" value={hotAuctions.length} subtitle="20+ bids" icon={Flame} />
<StatCard title="Opportunities" value={opportunities.length} icon={Target} />
</div>
{/* Tabs */}
<TabBar tabs={tabs} activeTab={activeTab} onChange={(id) => setActiveTab(id as TabType)} />
{/* Smart Filter Presets */}
<div className="flex flex-wrap gap-2 p-1.5 bg-background-secondary/30 border border-border/30 rounded-xl">
{FILTER_PRESETS.map((preset) => {
const isDisabled = preset.proOnly && !isPaidUser
const isActive = filterPreset === preset.id
const Icon = preset.icon
return (
<button
key={preset.id}
onClick={() => !isDisabled && setFilterPreset(preset.id)}
disabled={isDisabled}
title={isDisabled ? 'Upgrade to Trader to use this filter' : preset.description}
className={clsx(
"flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg transition-all",
isActive
? "bg-accent text-background shadow-md"
: isDisabled
? "text-foreground-subtle opacity-50 cursor-not-allowed"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)}
>
<Icon className="w-4 h-4" />
<span className="hidden sm:inline">{preset.label}</span>
{preset.proOnly && !isPaidUser && <Crown className="w-3 h-3 text-amber-400" />}
</button>
)
})}
</div>
{/* Tier notification for Scout users */}
{!isPaidUser && (
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-center gap-3">
<div className="w-10 h-10 bg-amber-500/20 rounded-xl flex items-center justify-center shrink-0">
<Eye className="w-5 h-5 text-amber-400" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-foreground">You&apos;re seeing the raw auction feed</p>
<p className="text-xs text-foreground-muted">
Upgrade to Trader for spam-free listings, Deal Scores, and Smart Filters.
</p>
</div>
<Link
href="/pricing"
className="shrink-0 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg hover:bg-accent-hover transition-all"
>
Upgrade
</Link>
</div>
)}
{/* Filters */}
<FilterBar>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search domains..."
className="flex-1 min-w-[200px] max-w-md"
/>
<SelectDropdown value={selectedPlatform} onChange={setSelectedPlatform} options={PLATFORMS} />
<div className="relative">
<DollarSign className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
<input
type="number"
placeholder="Max bid"
value={maxBid}
onChange={(e) => setMaxBid(e.target.value)}
className="w-28 h-10 pl-9 pr-3 bg-background-secondary/50 border border-border/40 rounded-xl
text-sm text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:border-accent/50 transition-all"
/>
</div>
</FilterBar>
{/* Table */}
<PremiumTable
data={sortedAuctions}
keyExtractor={(a) => `${a.domain}-${a.platform}`}
loading={loading}
sortBy={sortBy}
sortDirection={sortDirection}
onSort={handleSort}
emptyIcon={<Gavel className="w-12 h-12 text-foreground-subtle" />}
emptyTitle={searchQuery ? `No auctions matching "${searchQuery}"` : "No auctions found"}
emptyDescription="Try adjusting your filters or check back later"
columns={columns}
/>
</PageContainer>
</TerminalLayout>
)
}

View File

@ -1,302 +0,0 @@
'use client'
import { useEffect, useState, useMemo, useCallback } from 'react'
import { api } from '@/lib/api'
import { TerminalLayout } from '@/components/TerminalLayout'
import {
PageContainer,
StatCard,
Badge,
SearchInput,
FilterBar,
SelectDropdown,
ActionButton,
} from '@/components/PremiumTable'
import {
Search,
Shield,
Loader2,
ExternalLink,
Store,
Tag,
DollarSign,
Filter,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
interface Listing {
domain: string
slug: string
title: string | null
description: string | null
asking_price: number | null
currency: string
price_type: string
pounce_score: number | null
estimated_value: number | null
is_verified: boolean
allow_offers: boolean
public_url: string
seller_verified: boolean
}
type SortOption = 'newest' | 'price_asc' | 'price_desc' | 'score'
export default function CommandMarketplacePage() {
const [listings, setListings] = useState<Listing[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [minPrice, setMinPrice] = useState('')
const [maxPrice, setMaxPrice] = useState('')
const [verifiedOnly, setVerifiedOnly] = useState(false)
const [sortBy, setSortBy] = useState<SortOption>('newest')
const [showFilters, setShowFilters] = useState(false)
const loadListings = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams()
params.set('limit', '100')
if (sortBy === 'price_asc') params.set('sort', 'price_asc')
if (sortBy === 'price_desc') params.set('sort', 'price_desc')
if (verifiedOnly) params.set('verified_only', 'true')
const data = await api.request<Listing[]>(`/listings?${params.toString()}`)
setListings(data)
} catch (err) {
console.error('Failed to load listings:', err)
} finally {
setLoading(false)
}
}, [sortBy, verifiedOnly])
useEffect(() => {
loadListings()
}, [loadListings])
const formatPrice = (price: number | null, currency: string) => {
if (!price) return 'Make Offer'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 0,
}).format(price)
}
// Memoized filtered and sorted listings
const sortedListings = useMemo(() => {
let result = listings.filter(listing => {
if (searchQuery && !listing.domain.toLowerCase().includes(searchQuery.toLowerCase())) return false
if (minPrice && listing.asking_price && listing.asking_price < parseFloat(minPrice)) return false
if (maxPrice && listing.asking_price && listing.asking_price > parseFloat(maxPrice)) return false
return true
})
return result.sort((a, b) => {
switch (sortBy) {
case 'price_asc': return (a.asking_price || 0) - (b.asking_price || 0)
case 'price_desc': return (b.asking_price || 0) - (a.asking_price || 0)
case 'score': return (b.pounce_score || 0) - (a.pounce_score || 0)
default: return 0
}
})
}, [listings, searchQuery, minPrice, maxPrice, sortBy])
// Memoized stats
const stats = useMemo(() => {
const verifiedCount = listings.filter(l => l.is_verified).length
const pricesWithValue = listings.filter(l => l.asking_price)
const avgPrice = pricesWithValue.length > 0
? pricesWithValue.reduce((sum, l) => sum + (l.asking_price || 0), 0) / pricesWithValue.length
: 0
return { verifiedCount, avgPrice }
}, [listings])
return (
<TerminalLayout
title="Marketplace"
subtitle={`${listings.length} premium domains for sale`}
actions={
<Link href="/terminal/listing">
<ActionButton icon={Tag} variant="secondary">My Listings</ActionButton>
</Link>
}
>
<PageContainer>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Total Listings" value={listings.length} icon={Store} />
<StatCard title="Verified Sellers" value={stats.verifiedCount} icon={Shield} />
<StatCard
title="Avg. Price"
value={stats.avgPrice > 0 ? `$${Math.round(stats.avgPrice).toLocaleString()}` : '—'}
icon={DollarSign}
/>
<StatCard title="Results" value={sortedListings.length} icon={Search} />
</div>
{/* Search & Filters */}
<div className="p-4 bg-background-secondary/30 border border-border rounded-2xl space-y-4">
<div className="flex flex-wrap gap-3">
{/* Search */}
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
<input
type="text"
placeholder="Search domains..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-12 pr-4 py-3 bg-background border border-border rounded-xl
text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
/>
</div>
{/* Sort */}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="px-4 py-3 bg-background border border-border rounded-xl text-foreground
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
>
<option value="newest">Newest First</option>
<option value="price_asc">Price: Low to High</option>
<option value="price_desc">Price: High to Low</option>
<option value="score">Pounce Score</option>
</select>
{/* Filter Toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className={clsx(
"flex items-center gap-2 px-4 py-3 border rounded-xl transition-all",
showFilters
? "bg-accent/10 border-accent/30 text-accent"
: "bg-background border-border text-foreground-muted hover:text-foreground"
)}
>
<Filter className="w-4 h-4" />
Filters
</button>
</div>
{/* Expanded Filters */}
{showFilters && (
<div className="flex flex-wrap items-center gap-4 pt-3 border-t border-border/50">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground-muted">Price:</span>
<input
type="number"
placeholder="Min"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
className="w-24 px-3 py-2 bg-background border border-border rounded-lg text-sm text-foreground
placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
<span className="text-foreground-subtle"></span>
<input
type="number"
placeholder="Max"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
className="w-24 px-3 py-2 bg-background border border-border rounded-lg text-sm text-foreground
placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={verifiedOnly}
onChange={(e) => setVerifiedOnly(e.target.checked)}
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
/>
<span className="text-sm text-foreground">Verified sellers only</span>
</label>
</div>
)}
</div>
{/* Listings Grid */}
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
) : sortedListings.length === 0 ? (
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-2xl">
<Store className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
<h2 className="text-xl font-medium text-foreground mb-2">No Domains Found</h2>
<p className="text-foreground-muted mb-6">
{searchQuery || minPrice || maxPrice
? 'Try adjusting your filters'
: 'No domains are currently listed for sale'}
</p>
<Link
href="/terminal/listing"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
>
<Tag className="w-5 h-5" />
List Your Domain
</Link>
</div>
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedListings.map((listing) => (
<Link
key={listing.slug}
href={`/buy/${listing.slug}`}
className="group p-5 bg-background-secondary/30 border border-border rounded-2xl
hover:border-accent/50 hover:bg-background-secondary/50 transition-all duration-300"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="font-mono text-lg font-medium text-foreground group-hover:text-accent transition-colors truncate">
{listing.domain}
</h3>
{listing.title && (
<p className="text-sm text-foreground-muted truncate mt-1">{listing.title}</p>
)}
</div>
{listing.is_verified && (
<div className="shrink-0 ml-2 w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center" title="Verified Seller">
<Shield className="w-4 h-4 text-accent" />
</div>
)}
</div>
{listing.description && (
<p className="text-sm text-foreground-subtle line-clamp-2 mb-4">
{listing.description}
</p>
)}
<div className="flex items-end justify-between">
<div className="flex items-center gap-2">
{listing.pounce_score && (
<div className="px-2 py-1 bg-accent/10 text-accent rounded text-sm font-medium">
{listing.pounce_score}
</div>
)}
{listing.allow_offers && (
<Badge variant="accent">Offers</Badge>
)}
</div>
<div className="text-right">
<p className="text-xl font-semibold text-foreground">
{formatPrice(listing.asking_price, listing.currency)}
</p>
{listing.price_type === 'negotiable' && (
<p className="text-xs text-accent">Negotiable</p>
)}
</div>
</div>
</Link>
))}
</div>
)}
</PageContainer>
</TerminalLayout>
)
}

View File

@ -1,951 +0,0 @@
'use client'
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
import { useStore } from '@/lib/store'
import { api, PortfolioDomain, PortfolioSummary, DomainValuation, DomainHealthReport, HealthStatus } from '@/lib/api'
import { TerminalLayout } from '@/components/TerminalLayout'
import { PremiumTable, StatCard, PageContainer, ActionButton } from '@/components/PremiumTable'
import { Toast, useToast } from '@/components/Toast'
import {
Plus,
Trash2,
Edit2,
DollarSign,
Calendar,
Building,
Loader2,
ArrowUpRight,
X,
Briefcase,
ShoppingCart,
Activity,
Shield,
AlertTriangle,
Tag,
MoreVertical,
ExternalLink,
} from 'lucide-react'
import clsx from 'clsx'
import Link from 'next/link'
// Health status configuration
const healthStatusConfig: Record<HealthStatus, {
label: string
color: string
bgColor: string
icon: typeof Activity
}> = {
healthy: { label: 'Healthy', color: 'text-accent', bgColor: 'bg-accent/10', icon: Activity },
weakening: { label: 'Weak', color: 'text-amber-400', bgColor: 'bg-amber-400/10', icon: AlertTriangle },
parked: { label: 'Parked', color: 'text-orange-400', bgColor: 'bg-orange-400/10', icon: ShoppingCart },
critical: { label: 'Critical', color: 'text-red-400', bgColor: 'bg-red-400/10', icon: AlertTriangle },
unknown: { label: 'Unknown', color: 'text-foreground-muted', bgColor: 'bg-foreground/5', icon: Activity },
}
export default function PortfolioPage() {
const { subscription } = useStore()
const { toast, showToast, hideToast } = useToast()
const [portfolio, setPortfolio] = useState<PortfolioDomain[]>([])
const [summary, setSummary] = useState<PortfolioSummary | null>(null)
const [loading, setLoading] = useState(true)
const [showAddModal, setShowAddModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [showSellModal, setShowSellModal] = useState(false)
const [showValuationModal, setShowValuationModal] = useState(false)
const [selectedDomain, setSelectedDomain] = useState<PortfolioDomain | null>(null)
const [valuation, setValuation] = useState<DomainValuation | null>(null)
const [valuatingDomain, setValuatingDomain] = useState('')
const [addingDomain, setAddingDomain] = useState(false)
const [savingEdit, setSavingEdit] = useState(false)
const [processingSale, setProcessingSale] = useState(false)
const [refreshingId, setRefreshingId] = useState<number | null>(null)
// Health monitoring state
const [healthReports, setHealthReports] = useState<Record<string, DomainHealthReport>>({})
const [loadingHealth, setLoadingHealth] = useState<Record<string, boolean>>({})
const [selectedHealthDomain, setSelectedHealthDomain] = useState<string | null>(null)
// Dropdown menu state
const [openMenuId, setOpenMenuId] = useState<number | null>(null)
const [addForm, setAddForm] = useState({
domain: '',
purchase_price: '',
purchase_date: '',
registrar: '',
renewal_date: '',
renewal_cost: '',
notes: '',
})
const [editForm, setEditForm] = useState({
purchase_price: '',
purchase_date: '',
registrar: '',
renewal_date: '',
renewal_cost: '',
notes: '',
})
const [sellForm, setSellForm] = useState({
sale_date: new Date().toISOString().split('T')[0],
sale_price: '',
})
const loadPortfolio = useCallback(async () => {
setLoading(true)
try {
const [portfolioData, summaryData] = await Promise.all([
api.getPortfolio(),
api.getPortfolioSummary(),
])
setPortfolio(portfolioData)
setSummary(summaryData)
} catch (error) {
console.error('Failed to load portfolio:', error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadPortfolio()
}, [loadPortfolio])
const handleAddDomain = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
if (!addForm.domain.trim()) return
setAddingDomain(true)
try {
await api.addPortfolioDomain({
domain: addForm.domain.trim(),
purchase_price: addForm.purchase_price ? parseFloat(addForm.purchase_price) : undefined,
purchase_date: addForm.purchase_date || undefined,
registrar: addForm.registrar || undefined,
renewal_date: addForm.renewal_date || undefined,
renewal_cost: addForm.renewal_cost ? parseFloat(addForm.renewal_cost) : undefined,
notes: addForm.notes || undefined,
})
showToast(`Added ${addForm.domain} to portfolio`, 'success')
setAddForm({ domain: '', purchase_price: '', purchase_date: '', registrar: '', renewal_date: '', renewal_cost: '', notes: '' })
setShowAddModal(false)
loadPortfolio()
} catch (err: any) {
showToast(err.message || 'Failed to add domain', 'error')
} finally {
setAddingDomain(false)
}
}, [addForm, loadPortfolio, showToast])
const handleEditDomain = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedDomain) return
setSavingEdit(true)
try {
await api.updatePortfolioDomain(selectedDomain.id, {
purchase_price: editForm.purchase_price ? parseFloat(editForm.purchase_price) : undefined,
purchase_date: editForm.purchase_date || undefined,
registrar: editForm.registrar || undefined,
renewal_date: editForm.renewal_date || undefined,
renewal_cost: editForm.renewal_cost ? parseFloat(editForm.renewal_cost) : undefined,
notes: editForm.notes || undefined,
})
showToast('Domain updated', 'success')
setShowEditModal(false)
loadPortfolio()
} catch (err: any) {
showToast(err.message || 'Failed to update', 'error')
} finally {
setSavingEdit(false)
}
}, [selectedDomain, editForm, loadPortfolio, showToast])
const handleSellDomain = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedDomain || !sellForm.sale_price) return
setProcessingSale(true)
try {
await api.markDomainSold(selectedDomain.id, sellForm.sale_date, parseFloat(sellForm.sale_price))
showToast(`Marked ${selectedDomain.domain} as sold`, 'success')
setShowSellModal(false)
loadPortfolio()
} catch (err: any) {
showToast(err.message || 'Failed to process sale', 'error')
} finally {
setProcessingSale(false)
}
}, [selectedDomain, sellForm, loadPortfolio, showToast])
const handleValuate = useCallback(async (domain: PortfolioDomain) => {
setValuatingDomain(domain.domain)
setShowValuationModal(true)
try {
const result = await api.getDomainValuation(domain.domain)
setValuation(result)
} catch (err: any) {
showToast(err.message || 'Failed to get valuation', 'error')
setShowValuationModal(false)
} finally {
setValuatingDomain('')
}
}, [showToast])
const handleRefresh = useCallback(async (domain: PortfolioDomain) => {
setRefreshingId(domain.id)
try {
await api.refreshDomainValue(domain.id)
showToast('Valuation refreshed', 'success')
loadPortfolio()
} catch (err: any) {
showToast(err.message || 'Failed to refresh', 'error')
} finally {
setRefreshingId(null)
}
}, [loadPortfolio, showToast])
const handleHealthCheck = useCallback(async (domainName: string) => {
if (loadingHealth[domainName]) return
setLoadingHealth(prev => ({ ...prev, [domainName]: true }))
try {
const report = await api.quickHealthCheck(domainName)
setHealthReports(prev => ({ ...prev, [domainName]: report }))
setSelectedHealthDomain(domainName)
} catch (err: any) {
showToast(err.message || 'Health check failed', 'error')
} finally {
setLoadingHealth(prev => ({ ...prev, [domainName]: false }))
}
}, [loadingHealth, showToast])
const handleDelete = useCallback(async (domain: PortfolioDomain) => {
if (!confirm(`Remove ${domain.domain} from your portfolio?`)) return
try {
await api.deletePortfolioDomain(domain.id)
showToast(`Removed ${domain.domain}`, 'success')
loadPortfolio()
} catch (err: any) {
showToast(err.message || 'Failed to remove', 'error')
}
}, [loadPortfolio, showToast])
const openEditModal = useCallback((domain: PortfolioDomain) => {
setSelectedDomain(domain)
setEditForm({
purchase_price: domain.purchase_price?.toString() || '',
purchase_date: domain.purchase_date || '',
registrar: domain.registrar || '',
renewal_date: domain.renewal_date || '',
renewal_cost: domain.renewal_cost?.toString() || '',
notes: domain.notes || '',
})
setShowEditModal(true)
}, [])
const openSellModal = useCallback((domain: PortfolioDomain) => {
setSelectedDomain(domain)
setSellForm({
sale_date: new Date().toISOString().split('T')[0],
sale_price: '',
})
setShowSellModal(true)
}, [])
const portfolioLimit = subscription?.portfolio_limit || 0
const canAddMore = portfolioLimit === -1 || portfolio.length < portfolioLimit
// Memoized stats and subtitle
const { expiringSoonCount, subtitle } = useMemo(() => {
const expiring = portfolio.filter(d => {
if (!d.renewal_date) return false
const days = Math.ceil((new Date(d.renewal_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
return days <= 30 && days > 0
}).length
let sub = ''
if (loading) sub = 'Loading your portfolio...'
else if (portfolio.length === 0) sub = 'Start tracking your domains'
else if (expiring > 0) sub = `${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''}${expiring} expiring soon`
else sub = `Managing ${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''}`
return { expiringSoonCount: expiring, subtitle: sub }
}, [portfolio, loading])
return (
<TerminalLayout
title="Portfolio"
subtitle={subtitle}
actions={
<ActionButton onClick={() => setShowAddModal(true)} disabled={!canAddMore} icon={Plus}>
Add Domain
</ActionButton>
}
>
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
<PageContainer>
{/* Summary Stats - Only reliable data */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Total Domains" value={summary?.total_domains || 0} icon={Briefcase} />
<StatCard title="Expiring Soon" value={expiringSoonCount} icon={Calendar} />
<StatCard
title="Need Attention"
value={Object.values(healthReports).filter(r => r.status !== 'healthy').length}
icon={AlertTriangle}
/>
<StatCard title="Listed for Sale" value={summary?.sold_domains || 0} icon={Tag} />
</div>
{!canAddMore && (
<div className="flex items-center justify-between p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl">
<p className="text-sm text-amber-400">
You've reached your portfolio limit. Upgrade to add more.
</p>
<Link
href="/pricing"
className="text-sm font-medium text-amber-400 hover:text-amber-300 flex items-center gap-1"
>
Upgrade <ArrowUpRight className="w-3 h-3" />
</Link>
</div>
)}
{/* Portfolio Table */}
<PremiumTable
data={portfolio}
keyExtractor={(d) => d.id}
loading={loading}
emptyIcon={<Briefcase className="w-12 h-12 text-foreground-subtle" />}
emptyTitle="Your portfolio is empty"
emptyDescription="Add your first domain to start tracking investments"
columns={[
{
key: 'domain',
header: 'Domain',
render: (domain) => (
<div>
<span className="font-mono font-medium text-foreground">{domain.domain}</span>
{domain.registrar && (
<p className="text-xs text-foreground-muted flex items-center gap-1 mt-0.5">
<Building className="w-3 h-3" /> {domain.registrar}
</p>
)}
</div>
),
},
{
key: 'added',
header: 'Added',
hideOnMobile: true,
hideOnTablet: true,
render: (domain) => (
<span className="text-sm text-foreground-muted">
{domain.purchase_date
? new Date(domain.purchase_date).toLocaleDateString()
: new Date(domain.created_at).toLocaleDateString()
}
</span>
),
},
{
key: 'renewal',
header: 'Expires',
hideOnMobile: true,
render: (domain) => {
if (!domain.renewal_date) {
return <span className="text-foreground-subtle">—</span>
}
const days = Math.ceil((new Date(domain.renewal_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
const isExpiringSoon = days <= 30 && days > 0
const isExpired = days <= 0
return (
<div className="flex items-center gap-2">
<span className={clsx(
"text-sm",
isExpired && "text-red-400",
isExpiringSoon && "text-amber-400",
!isExpired && !isExpiringSoon && "text-foreground-muted"
)}>
{new Date(domain.renewal_date).toLocaleDateString()}
</span>
{isExpiringSoon && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-amber-400/10 text-amber-400 rounded">
{days}d
</span>
)}
{isExpired && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-400/10 text-red-400 rounded">
EXPIRED
</span>
)}
</div>
)
},
},
{
key: 'health',
header: 'Health',
hideOnMobile: true,
render: (domain) => {
const report = healthReports[domain.domain]
if (loadingHealth[domain.domain]) {
return <Loader2 className="w-4 h-4 text-foreground-muted animate-spin" />
}
if (report) {
const config = healthStatusConfig[report.status]
const Icon = config.icon
return (
<button
onClick={() => setSelectedHealthDomain(domain.domain)}
className={clsx(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-medium",
config.bgColor, config.color
)}
>
<Icon className="w-3 h-3" />
{config.label}
</button>
)
}
return (
<button
onClick={() => handleHealthCheck(domain.domain)}
className="text-xs text-foreground-muted hover:text-accent transition-colors flex items-center gap-1"
>
<Activity className="w-3.5 h-3.5" />
Check
</button>
)
},
},
{
key: 'actions',
header: '',
align: 'right',
render: (domain) => (
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation()
setOpenMenuId(openMenuId === domain.id ? null : domain.id)
}}
className="p-2 text-foreground-muted hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
>
<MoreVertical className="w-4 h-4" />
</button>
{openMenuId === domain.id && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setOpenMenuId(null)}
/>
{/* Menu - opens downward */}
<div className="absolute right-0 top-full mt-1 z-50 w-48 py-1 bg-background-secondary border border-border/50 rounded-xl shadow-xl">
<button
onClick={() => { handleHealthCheck(domain.domain); setOpenMenuId(null) }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
>
<Shield className="w-4 h-4" />
Health Check
</button>
<button
onClick={() => { openEditModal(domain); setOpenMenuId(null) }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
>
<Edit2 className="w-4 h-4" />
Edit Details
</button>
<div className="my-1 border-t border-border/30" />
<Link
href={`/terminal/listing?domain=${encodeURIComponent(domain.domain)}`}
onClick={() => setOpenMenuId(null)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-accent hover:bg-accent/5 transition-colors"
>
<Tag className="w-4 h-4" />
List for Sale
</Link>
<a
href={`https://${domain.domain}`}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpenMenuId(null)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
>
<ExternalLink className="w-4 h-4" />
Visit Website
</a>
<div className="my-1 border-t border-border/30" />
<button
onClick={() => { openSellModal(domain); setOpenMenuId(null) }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
>
<DollarSign className="w-4 h-4" />
Record Sale
</button>
<button
onClick={() => { handleDelete(domain); setOpenMenuId(null) }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-400 hover:bg-red-400/5 transition-colors"
>
<Trash2 className="w-4 h-4" />
Remove
</button>
</div>
</>
)}
</div>
),
},
]}
/>
</PageContainer>
{/* Add Modal */}
{showAddModal && (
<Modal title="Add Domain to Portfolio" onClose={() => setShowAddModal(false)}>
<form onSubmit={handleAddDomain} className="space-y-4">
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Domain *</label>
<input
type="text"
value={addForm.domain}
onChange={(e) => setAddForm({ ...addForm, domain: e.target.value })}
placeholder="example.com"
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50 transition-all"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Purchase Price</label>
<input
type="number"
value={addForm.purchase_price}
onChange={(e) => setAddForm({ ...addForm, purchase_price: e.target.value })}
placeholder="100"
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Purchase Date</label>
<input
type="date"
value={addForm.purchase_date}
onChange={(e) => setAddForm({ ...addForm, purchase_date: e.target.value })}
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50"
/>
</div>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Registrar</label>
<input
type="text"
value={addForm.registrar}
onChange={(e) => setAddForm({ ...addForm, registrar: e.target.value })}
placeholder="Namecheap"
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50"
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setShowAddModal(false)}
className="px-4 py-2.5 text-foreground-muted hover:text-foreground transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={addingDomain || !addForm.domain.trim()}
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-accent to-accent/80
text-background rounded-xl font-medium disabled:opacity-50 transition-all"
>
{addingDomain && <Loader2 className="w-4 h-4 animate-spin" />}
Add Domain
</button>
</div>
</form>
</Modal>
)}
{/* Edit Modal */}
{showEditModal && selectedDomain && (
<Modal title={`Edit ${selectedDomain.domain}`} onClose={() => setShowEditModal(false)}>
<form onSubmit={handleEditDomain} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Purchase Price</label>
<input
type="number"
value={editForm.purchase_price}
onChange={(e) => setEditForm({ ...editForm, purchase_price: e.target.value })}
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Registrar</label>
<input
type="text"
value={editForm.registrar}
onChange={(e) => setEditForm({ ...editForm, registrar: e.target.value })}
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="px-4 py-2.5 text-foreground-muted hover:text-foreground transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={savingEdit}
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-accent to-accent/80
text-background rounded-xl font-medium disabled:opacity-50 transition-all"
>
{savingEdit && <Loader2 className="w-4 h-4 animate-spin" />}
Save Changes
</button>
</div>
</form>
</Modal>
)}
{/* Record Sale Modal - for tracking completed sales */}
{showSellModal && selectedDomain && (
<Modal title={`Record Sale: ${selectedDomain.domain}`} onClose={() => setShowSellModal(false)}>
<form onSubmit={handleSellDomain} className="space-y-4">
<div className="p-3 bg-accent/10 border border-accent/20 rounded-lg text-sm text-foreground-muted">
<p>Record a completed sale to track your profit/loss. This will mark the domain as sold in your portfolio.</p>
<p className="mt-2 text-accent">Want to list it for sale instead? Use the <strong>"List"</strong> button.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Sale Price *</label>
<input
type="number"
value={sellForm.sale_price}
onChange={(e) => setSellForm({ ...sellForm, sale_price: e.target.value })}
placeholder="1000"
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50"
required
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1.5">Sale Date</label>
<input
type="date"
value={sellForm.sale_date}
onChange={(e) => setSellForm({ ...sellForm, sale_date: e.target.value })}
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl text-foreground
focus:outline-none focus:border-accent/50"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setShowSellModal(false)}
className="px-4 py-2.5 text-foreground-muted hover:text-foreground transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={processingSale || !sellForm.sale_price}
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-accent to-accent/80
text-background rounded-xl font-medium disabled:opacity-50 transition-all"
>
{processingSale && <Loader2 className="w-4 h-4 animate-spin" />}
Mark as Sold
</button>
</div>
</form>
</Modal>
)}
{/* Valuation Modal */}
{showValuationModal && (
<Modal title="Domain Valuation" onClose={() => { setShowValuationModal(false); setValuation(null); }}>
{valuatingDomain ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-accent" />
</div>
) : valuation ? (
<div className="space-y-4">
<div className="text-center p-6 bg-accent/5 border border-accent/20 rounded-xl">
<p className="text-4xl font-semibold text-accent">${valuation.estimated_value.toLocaleString()}</p>
<p className="text-sm text-foreground-muted mt-1">Pounce Score Estimate</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between items-center p-3 bg-foreground/5 rounded-lg">
<span className="text-foreground-muted">Confidence Level</span>
<span className={clsx(
"px-2 py-0.5 rounded text-xs font-medium capitalize",
valuation.confidence === 'high' && "bg-accent/20 text-accent",
valuation.confidence === 'medium' && "bg-amber-400/20 text-amber-400",
valuation.confidence === 'low' && "bg-foreground/10 text-foreground-muted"
)}>
{valuation.confidence}
</span>
</div>
<div className="p-3 bg-amber-400/10 border border-amber-400/20 rounded-lg text-xs text-amber-400">
<p>This is an algorithmic estimate based on domain length, TLD, and market patterns. Actual market value may vary.</p>
</div>
</div>
</div>
) : null}
</Modal>
)}
{/* Health Report Modal */}
{selectedHealthDomain && healthReports[selectedHealthDomain] && (
<HealthReportModal
report={healthReports[selectedHealthDomain]}
onClose={() => setSelectedHealthDomain(null)}
/>
)}
</TerminalLayout>
)
}
// Health Report Modal Component
function HealthReportModal({ report, onClose }: { report: DomainHealthReport; onClose: () => void }) {
const config = healthStatusConfig[report.status]
const Icon = config.icon
return (
<div
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="w-full max-w-lg bg-background-secondary border border-border/50 rounded-2xl shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-border/50">
<div className="flex items-center gap-3">
<div className={clsx("p-2 rounded-lg", config.bgColor)}>
<Icon className={clsx("w-5 h-5", config.color)} />
</div>
<div>
<h3 className="font-mono font-semibold text-foreground">{report.domain}</h3>
<p className={clsx("text-xs font-medium", config.color)}>{config.label}</p>
</div>
</div>
<button onClick={onClose} className="p-1 text-foreground-muted hover:text-foreground transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Score */}
<div className="p-5 border-b border-border/30">
<div className="flex items-center justify-between">
<span className="text-sm text-foreground-muted">Health Score</span>
<div className="flex items-center gap-3">
<div className="w-32 h-2 bg-foreground/10 rounded-full overflow-hidden">
<div
className={clsx(
"h-full rounded-full transition-all",
report.score >= 70 ? "bg-accent" :
report.score >= 40 ? "bg-amber-400" : "bg-red-400"
)}
style={{ width: `${report.score}%` }}
/>
</div>
<span className={clsx(
"text-lg font-bold tabular-nums",
report.score >= 70 ? "text-accent" :
report.score >= 40 ? "text-amber-400" : "text-red-400"
)}>
{report.score}/100
</span>
</div>
</div>
</div>
{/* Check Results */}
<div className="p-5 space-y-4">
{/* DNS */}
{report.dns && (
<div className="p-4 bg-foreground/5 rounded-xl">
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<span className={clsx(
"w-2 h-2 rounded-full",
report.dns.has_ns && report.dns.has_a ? "bg-accent" : "bg-red-400"
)} />
DNS Infrastructure
</h4>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex items-center gap-1.5">
<span className={report.dns.has_ns ? "text-accent" : "text-red-400"}>
{report.dns.has_ns ? '' : ''}
</span>
<span className="text-foreground-muted">Nameservers</span>
</div>
<div className="flex items-center gap-1.5">
<span className={report.dns.has_a ? "text-accent" : "text-red-400"}>
{report.dns.has_a ? '' : ''}
</span>
<span className="text-foreground-muted">A Record</span>
</div>
<div className="flex items-center gap-1.5">
<span className={report.dns.has_mx ? "text-accent" : "text-foreground-muted"}>
{report.dns.has_mx ? '' : ''}
</span>
<span className="text-foreground-muted">MX Record</span>
</div>
</div>
{report.dns.is_parked && (
<p className="mt-2 text-xs text-orange-400">⚠ Parked at {report.dns.parking_provider || 'unknown provider'}</p>
)}
</div>
)}
{/* HTTP */}
{report.http && (
<div className="p-4 bg-foreground/5 rounded-xl">
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<span className={clsx(
"w-2 h-2 rounded-full",
report.http.is_reachable && report.http.status_code === 200 ? "bg-accent" :
report.http.is_reachable ? "bg-amber-400" : "bg-red-400"
)} />
Website Status
</h4>
<div className="flex items-center gap-4 text-xs">
<span className={clsx(
report.http.is_reachable ? "text-accent" : "text-red-400"
)}>
{report.http.is_reachable ? 'Reachable' : 'Unreachable'}
</span>
{report.http.status_code && (
<span className="text-foreground-muted">
HTTP {report.http.status_code}
</span>
)}
</div>
{report.http.is_parked && (
<p className="mt-2 text-xs text-orange-400">⚠ Parking page detected</p>
)}
</div>
)}
{/* SSL */}
{report.ssl && (
<div className="p-4 bg-foreground/5 rounded-xl">
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<span className={clsx(
"w-2 h-2 rounded-full",
report.ssl.has_certificate && report.ssl.is_valid ? "bg-accent" :
report.ssl.has_certificate ? "bg-amber-400" : "bg-foreground-muted"
)} />
SSL Certificate
</h4>
<div className="text-xs">
{report.ssl.has_certificate ? (
<div className="space-y-1">
<p className={report.ssl.is_valid ? "text-accent" : "text-red-400"}>
{report.ssl.is_valid ? ' Valid certificate' : ' Certificate invalid/expired'}
</p>
{report.ssl.days_until_expiry !== undefined && (
<p className={clsx(
report.ssl.days_until_expiry > 30 ? "text-foreground-muted" :
report.ssl.days_until_expiry > 7 ? "text-amber-400" : "text-red-400"
)}>
Expires in {report.ssl.days_until_expiry} days
</p>
)}
</div>
) : (
<p className="text-foreground-muted">No SSL certificate</p>
)}
</div>
</div>
)}
{/* Signals & Recommendations */}
{((report.signals?.length || 0) > 0 || (report.recommendations?.length || 0) > 0) && (
<div className="space-y-3">
{(report.signals?.length || 0) > 0 && (
<div>
<h4 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">Signals</h4>
<ul className="space-y-1">
{report.signals?.map((signal, i) => (
<li key={i} className="text-xs text-foreground flex items-start gap-2">
<span className="text-accent mt-0.5"></span>
{signal}
</li>
))}
</ul>
</div>
)}
{(report.recommendations?.length || 0) > 0 && (
<div>
<h4 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">Recommendations</h4>
<ul className="space-y-1">
{report.recommendations?.map((rec, i) => (
<li key={i} className="text-xs text-foreground flex items-start gap-2">
<span className="text-amber-400 mt-0.5"></span>
{rec}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 bg-foreground/5 border-t border-border/30">
<p className="text-xs text-foreground-subtle text-center">
Checked at {new Date(report.checked_at).toLocaleString()}
</p>
</div>
</div>
</div>
)
}
// Modal Component
function Modal({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {
return (
<div
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="w-full max-w-md bg-background-secondary border border-border/50 rounded-2xl shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-5 border-b border-border/50">
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
<button onClick={onClose} className="p-1 text-foreground-muted hover:text-foreground transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-5">
{children}
</div>
</div>
</div>
)
}

View File

@ -200,10 +200,10 @@ export default function DashboardPage() {
accent={availableDomains.length > 0}
/>
</Link>
<Link href="/terminal/portfolio" className="group">
<Link href="/terminal/watchlist" className="group">
<StatCard
title="Portfolio"
value={0}
title="Watchlist"
value={totalDomains}
icon={Briefcase}
/>
</Link>
@ -291,7 +291,7 @@ export default function DashboardPage() {
icon={Gavel}
compact
action={
<Link href="/terminal/auctions" className="text-sm text-accent hover:text-accent/80 transition-colors">
<Link href="/terminal/market" className="text-sm text-accent hover:text-accent/80 transition-colors">
View all →
</Link>
}

View File

@ -1,508 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { TerminalLayout } from '@/components/TerminalLayout'
import { PageContainer, StatCard, Badge } from '@/components/PremiumTable'
import {
Search,
Link2,
Globe,
Shield,
TrendingUp,
Loader2,
AlertCircle,
X,
ExternalLink,
Crown,
CheckCircle,
Sparkles,
BookOpen,
Building,
GraduationCap,
Newspaper,
Lock,
Star,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
interface SEOData {
domain: string
seo_score: number
value_category: string
metrics: {
domain_authority: number | null
page_authority: number | null
spam_score: number | null
total_backlinks: number | null
referring_domains: number | null
}
notable_links: {
has_wikipedia: boolean
has_gov: boolean
has_edu: boolean
has_news: boolean
notable_domains: string[]
}
top_backlinks: Array<{
domain: string
authority: number
page: string
}>
estimated_value: number | null
data_source: string
last_updated: string | null
is_estimated: boolean
}
export default function SEOPage() {
const { subscription } = useStore()
const [domain, setDomain] = useState('')
const [loading, setLoading] = useState(false)
const [seoData, setSeoData] = useState<SEOData | null>(null)
const [error, setError] = useState<string | null>(null)
const [recentSearches, setRecentSearches] = useState<string[]>([])
const tier = subscription?.tier?.toLowerCase() || 'scout'
const isTycoon = tier === 'tycoon'
useEffect(() => {
// Load recent searches from localStorage
const saved = localStorage.getItem('seo-recent-searches')
if (saved) {
setRecentSearches(JSON.parse(saved))
}
}, [])
const saveRecentSearch = (domain: string) => {
const updated = [domain, ...recentSearches.filter(d => d !== domain)].slice(0, 5)
setRecentSearches(updated)
localStorage.setItem('seo-recent-searches', JSON.stringify(updated))
}
const cleanDomain = (d: string): string => {
// Remove whitespace, protocol, www, and trailing slashes
return d.trim()
.toLowerCase()
.replace(/\s+/g, '')
.replace(/^https?:\/\//, '')
.replace(/^www\./, '')
.replace(/\/.*$/, '')
}
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault()
const cleanedDomain = cleanDomain(domain)
if (!cleanedDomain) return
setLoading(true)
setError(null)
setSeoData(null)
try {
const data = await api.request<SEOData>(`/seo/${encodeURIComponent(cleanedDomain)}`)
setSeoData(data)
saveRecentSearch(cleanedDomain)
} catch (err: any) {
setError(err.message || 'Failed to analyze domain')
} finally {
setLoading(false)
}
}
const handleQuickSearch = async (searchDomain: string) => {
const cleanedDomain = cleanDomain(searchDomain)
setDomain(cleanedDomain)
setLoading(true)
setError(null)
setSeoData(null)
try {
const data = await api.request<SEOData>(`/seo/${encodeURIComponent(cleanedDomain)}`)
setSeoData(data)
} catch (err: any) {
setError(err.message || 'Failed to analyze domain')
} finally {
setLoading(false)
}
}
const getScoreColor = (score: number) => {
if (score >= 60) return 'text-accent'
if (score >= 40) return 'text-amber-400'
if (score >= 20) return 'text-orange-400'
return 'text-foreground-muted'
}
const getScoreBg = (score: number) => {
if (score >= 60) return 'bg-accent/10 border-accent/30'
if (score >= 40) return 'bg-amber-500/10 border-amber-500/30'
if (score >= 20) return 'bg-orange-500/10 border-orange-500/30'
return 'bg-foreground/5 border-border'
}
const formatNumber = (num: number | null) => {
if (num === null) return '-'
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
return num.toString()
}
// Show upgrade prompt for non-Tycoon users
if (!isTycoon) {
return (
<TerminalLayout
title="SEO Juice Detector"
subtitle="Backlink analysis & domain authority"
>
<PageContainer>
<div className="text-center py-16 bg-gradient-to-br from-accent/10 to-accent/5 border border-accent/20 rounded-2xl">
<div className="w-20 h-20 bg-accent/20 rounded-full flex items-center justify-center mx-auto mb-6">
<Crown className="w-10 h-10 text-accent" />
</div>
<h2 className="text-2xl font-semibold text-foreground mb-3">Tycoon Feature</h2>
<p className="text-foreground-muted max-w-lg mx-auto mb-8">
SEO Juice Detector is a premium feature for serious domain investors.
Analyze backlinks, domain authority, and find hidden gems that SEO agencies pay
$100-$500 for even if the name is "ugly".
</p>
<div className="grid sm:grid-cols-3 gap-4 max-w-2xl mx-auto mb-8">
<div className="p-4 bg-background/50 rounded-xl">
<Link2 className="w-6 h-6 text-accent mx-auto mb-2" />
<p className="text-sm text-foreground font-medium">Backlink Analysis</p>
<p className="text-xs text-foreground-muted">Top referring domains</p>
</div>
<div className="p-4 bg-background/50 rounded-xl">
<TrendingUp className="w-6 h-6 text-accent mx-auto mb-2" />
<p className="text-sm text-foreground font-medium">Domain Authority</p>
<p className="text-xs text-foreground-muted">Moz DA/PA scores</p>
</div>
<div className="p-4 bg-background/50 rounded-xl">
<Star className="w-6 h-6 text-accent mx-auto mb-2" />
<p className="text-sm text-foreground font-medium">Notable Links</p>
<p className="text-xs text-foreground-muted">Wikipedia, .gov, .edu</p>
</div>
</div>
<Link
href="/pricing"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
>
<Crown className="w-5 h-5" />
Upgrade to Tycoon
</Link>
</div>
</PageContainer>
</TerminalLayout>
)
}
return (
<TerminalLayout
title="SEO Juice Detector"
subtitle="Analyze backlinks, domain authority & find hidden SEO gems"
>
<PageContainer>
{/* Error Message */}
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-400" />
<p className="text-sm text-red-400 flex-1">{error}</p>
<button onClick={() => setError(null)}><X className="w-4 h-4 text-red-400" /></button>
</div>
)}
{/* Search Form */}
<div className="p-6 bg-background-secondary/30 border border-border rounded-2xl">
<form onSubmit={handleSearch} className="flex gap-3">
<div className="relative flex-1">
<Globe className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
<input
type="text"
value={domain}
onChange={(e) => setDomain(e.target.value)}
placeholder="Enter domain to analyze (e.g., example.com)"
className="w-full pl-12 pr-4 py-3 bg-background border border-border rounded-xl
text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
/>
</div>
<button
type="submit"
disabled={loading || !domain.trim()}
className="flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl
hover:bg-accent-hover transition-all disabled:opacity-50"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Search className="w-5 h-5" />
)}
Analyze
</button>
</form>
{/* Recent Searches */}
{recentSearches.length > 0 && !seoData && (
<div className="mt-4 flex items-center gap-2 flex-wrap">
<span className="text-xs text-foreground-muted">Recent:</span>
{recentSearches.map((d) => (
<button
key={d}
onClick={() => handleQuickSearch(d)}
className="px-3 py-1 text-xs bg-foreground/5 text-foreground-muted rounded-full hover:bg-foreground/10 transition-colors"
>
{d}
</button>
))}
</div>
)}
</div>
{/* Loading State */}
{loading && (
<div className="flex flex-col items-center justify-center py-16">
<Loader2 className="w-8 h-8 text-accent animate-spin mb-4" />
<p className="text-foreground-muted">Analyzing backlinks & authority...</p>
</div>
)}
{/* Results */}
{seoData && !loading && (
<div className="space-y-6 animate-slide-up">
{/* Header with Score */}
<div className="p-6 bg-background-secondary/30 border border-border rounded-2xl">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="font-mono text-2xl font-medium text-foreground mb-1">
{seoData.domain}
</h2>
<div className="flex items-center gap-2">
<Badge variant={seoData.is_estimated ? 'warning' : 'success'}>
{seoData.data_source === 'moz' ? 'Moz Data' : 'Estimated'}
</Badge>
<span className="text-sm text-foreground-muted">{seoData.value_category}</span>
</div>
</div>
<div className={clsx(
"w-24 h-24 rounded-2xl border flex flex-col items-center justify-center",
getScoreBg(seoData.seo_score)
)}>
<span className={clsx("text-3xl font-semibold", getScoreColor(seoData.seo_score))}>
{seoData.seo_score}
</span>
<span className="text-xs text-foreground-muted">SEO Score</span>
</div>
</div>
{/* Estimated Value */}
{seoData.estimated_value && (
<div className="mt-4 p-4 bg-accent/10 border border-accent/20 rounded-xl">
<p className="text-sm text-foreground-muted mb-1">Estimated SEO Value</p>
<p className="text-2xl font-semibold text-accent">
${seoData.estimated_value.toLocaleString()}
</p>
<p className="text-xs text-foreground-subtle mt-1">
Based on domain authority & backlink profile
</p>
</div>
)}
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<StatCard
title="Domain Authority"
value={seoData.metrics.domain_authority || 0}
icon={TrendingUp}
subtitle="/100"
/>
<StatCard
title="Page Authority"
value={seoData.metrics.page_authority || 0}
icon={Globe}
subtitle="/100"
/>
<StatCard
title="Backlinks"
value={formatNumber(seoData.metrics.total_backlinks)}
icon={Link2}
/>
<StatCard
title="Referring Domains"
value={formatNumber(seoData.metrics.referring_domains)}
icon={ExternalLink}
/>
<StatCard
title="Spam Score"
value={seoData.metrics.spam_score || 0}
icon={Shield}
subtitle={seoData.metrics.spam_score && seoData.metrics.spam_score > 30 ? '⚠️ High' : '✓ Low'}
/>
</div>
{/* Notable Links */}
<div className="p-6 bg-background-secondary/30 border border-border rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">Notable Backlinks</h3>
<div className="grid sm:grid-cols-4 gap-4">
<div className={clsx(
"p-4 rounded-xl border flex items-center gap-3",
seoData.notable_links.has_wikipedia
? "bg-accent/10 border-accent/30"
: "bg-foreground/5 border-border"
)}>
<BookOpen className={clsx(
"w-6 h-6",
seoData.notable_links.has_wikipedia ? "text-accent" : "text-foreground-subtle"
)} />
<div>
<p className="text-sm font-medium text-foreground">Wikipedia</p>
<p className="text-xs text-foreground-muted">
{seoData.notable_links.has_wikipedia ? '✓ Found' : 'Not found'}
</p>
</div>
</div>
<div className={clsx(
"p-4 rounded-xl border flex items-center gap-3",
seoData.notable_links.has_gov
? "bg-accent/10 border-accent/30"
: "bg-foreground/5 border-border"
)}>
<Building className={clsx(
"w-6 h-6",
seoData.notable_links.has_gov ? "text-accent" : "text-foreground-subtle"
)} />
<div>
<p className="text-sm font-medium text-foreground">.gov Links</p>
<p className="text-xs text-foreground-muted">
{seoData.notable_links.has_gov ? '✓ Found' : 'Not found'}
</p>
</div>
</div>
<div className={clsx(
"p-4 rounded-xl border flex items-center gap-3",
seoData.notable_links.has_edu
? "bg-accent/10 border-accent/30"
: "bg-foreground/5 border-border"
)}>
<GraduationCap className={clsx(
"w-6 h-6",
seoData.notable_links.has_edu ? "text-accent" : "text-foreground-subtle"
)} />
<div>
<p className="text-sm font-medium text-foreground">.edu Links</p>
<p className="text-xs text-foreground-muted">
{seoData.notable_links.has_edu ? '✓ Found' : 'Not found'}
</p>
</div>
</div>
<div className={clsx(
"p-4 rounded-xl border flex items-center gap-3",
seoData.notable_links.has_news
? "bg-accent/10 border-accent/30"
: "bg-foreground/5 border-border"
)}>
<Newspaper className={clsx(
"w-6 h-6",
seoData.notable_links.has_news ? "text-accent" : "text-foreground-subtle"
)} />
<div>
<p className="text-sm font-medium text-foreground">News Sites</p>
<p className="text-xs text-foreground-muted">
{seoData.notable_links.has_news ? '✓ Found' : 'Not found'}
</p>
</div>
</div>
</div>
{/* Notable Domains List */}
{seoData.notable_links.notable_domains.length > 0 && (
<div className="mt-4">
<p className="text-sm text-foreground-muted mb-2">High-authority referring domains:</p>
<div className="flex flex-wrap gap-2">
{seoData.notable_links.notable_domains.map((d) => (
<span key={d} className="px-3 py-1 bg-accent/10 text-accent text-sm rounded-full">
{d}
</span>
))}
</div>
</div>
)}
</div>
{/* Top Backlinks */}
{seoData.top_backlinks.length > 0 && (
<div className="p-6 bg-background-secondary/30 border border-border rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">Top Backlinks</h3>
<div className="space-y-2">
{seoData.top_backlinks.map((link, idx) => (
<div
key={idx}
className="flex items-center justify-between p-3 bg-background rounded-xl border border-border/50"
>
<div className="flex items-center gap-3">
<div className={clsx(
"w-8 h-8 rounded-lg flex items-center justify-center text-sm font-medium",
link.authority >= 60 ? "bg-accent/10 text-accent" :
link.authority >= 40 ? "bg-amber-500/10 text-amber-400" :
"bg-foreground/5 text-foreground-muted"
)}>
{link.authority}
</div>
<div>
<p className="font-mono text-sm text-foreground">{link.domain}</p>
{link.page && (
<p className="text-xs text-foreground-muted truncate max-w-xs">{link.page}</p>
)}
</div>
</div>
<a
href={`https://${link.domain}`}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-foreground-subtle hover:text-accent transition-colors"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
))}
</div>
</div>
)}
{/* Data Source Note */}
{seoData.is_estimated && (
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl">
<p className="text-sm text-amber-400">
<AlertCircle className="w-4 h-4 inline mr-2" />
This data is estimated based on domain characteristics.
For live Moz data, configure MOZ_ACCESS_ID and MOZ_SECRET_KEY in the backend.
</p>
</div>
)}
</div>
)}
{/* Empty State */}
{!seoData && !loading && !error && (
<div className="text-center py-16">
<Sparkles className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
<h2 className="text-xl font-medium text-foreground mb-2">SEO Juice Detector</h2>
<p className="text-foreground-muted max-w-md mx-auto">
Enter a domain above to analyze its backlink profile, domain authority,
and find hidden SEO value that others miss.
</p>
</div>
)}
</PageContainer>
</TerminalLayout>
)
}

View File

@ -35,8 +35,8 @@ const planDetails = {
],
nextSteps: [
{ href: '/terminal/watchlist', label: 'Add domains to watchlist', icon: Eye },
{ href: '/terminal/alerts', label: 'Set up Sniper Alerts', icon: Bell },
{ href: '/terminal/portfolio', label: 'Track your portfolio', icon: BarChart3 },
{ href: '/terminal/market', label: 'Browse the market', icon: Store },
{ href: '/terminal/intel', label: 'Check TLD pricing', icon: BarChart3 },
],
},
tycoon: {
@ -53,8 +53,8 @@ const planDetails = {
],
nextSteps: [
{ href: '/terminal/watchlist', label: 'Add domains to watchlist', icon: Eye },
{ href: '/terminal/seo', label: 'Analyze SEO metrics', icon: Sparkles },
{ href: '/terminal/alerts', label: 'Create Sniper Alerts', icon: Bell },
{ href: '/terminal/market', label: 'Browse the market', icon: Store },
{ href: '/terminal/listing', label: 'List your domains', icon: Sparkles },
],
},
}

View File

@ -241,8 +241,8 @@ export function useUserShortcuts() {
// Navigation
{ key: 'g', label: 'Go to Dashboard', description: 'Navigate to dashboard', action: () => router.push('/terminal/radar'), category: 'navigation' },
{ key: 'w', label: 'Go to Watchlist', description: 'Navigate to watchlist', action: () => router.push('/terminal/watchlist'), category: 'navigation' },
{ key: 'p', label: 'Go to Portfolio', description: 'Navigate to portfolio', action: () => router.push('/terminal/portfolio'), category: 'navigation' },
{ key: 'a', label: 'Go to Auctions', description: 'Navigate to auctions', action: () => router.push('/terminal/auctions'), category: 'navigation' },
{ key: 'p', label: 'Go to Watchlist', description: 'Navigate to watchlist', action: () => router.push('/terminal/watchlist'), category: 'navigation' },
{ key: 'a', label: 'Go to Market', description: 'Navigate to market', action: () => router.push('/terminal/market'), category: 'navigation' },
{ key: 't', label: 'Go to TLD Pricing', description: 'Navigate to TLD pricing', action: () => router.push('/terminal/intel'), category: 'navigation' },
{ key: 's', label: 'Go to Settings', description: 'Navigate to settings', action: () => router.push('/terminal/settings'), category: 'navigation' },
// Actions