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:
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user