cleanup: Remove old Command Center files and fix all references
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- 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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<Link
|
<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"
|
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
|
Set Up
|
||||||
@ -536,7 +536,7 @@ export default function HomePage() {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<Link
|
<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"
|
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
|
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}
|
accent={availableDomains.length > 0}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/terminal/portfolio" className="group">
|
<Link href="/terminal/watchlist" className="group">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Portfolio"
|
title="Watchlist"
|
||||||
value={0}
|
value={totalDomains}
|
||||||
icon={Briefcase}
|
icon={Briefcase}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
@ -291,7 +291,7 @@ export default function DashboardPage() {
|
|||||||
icon={Gavel}
|
icon={Gavel}
|
||||||
compact
|
compact
|
||||||
action={
|
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 →
|
View all →
|
||||||
</Link>
|
</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: [
|
nextSteps: [
|
||||||
{ href: '/terminal/watchlist', label: 'Add domains to watchlist', icon: Eye },
|
{ href: '/terminal/watchlist', label: 'Add domains to watchlist', icon: Eye },
|
||||||
{ href: '/terminal/alerts', label: 'Set up Sniper Alerts', icon: Bell },
|
{ href: '/terminal/market', label: 'Browse the market', icon: Store },
|
||||||
{ href: '/terminal/portfolio', label: 'Track your portfolio', icon: BarChart3 },
|
{ href: '/terminal/intel', label: 'Check TLD pricing', icon: BarChart3 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
tycoon: {
|
tycoon: {
|
||||||
@ -53,8 +53,8 @@ const planDetails = {
|
|||||||
],
|
],
|
||||||
nextSteps: [
|
nextSteps: [
|
||||||
{ href: '/terminal/watchlist', label: 'Add domains to watchlist', icon: Eye },
|
{ href: '/terminal/watchlist', label: 'Add domains to watchlist', icon: Eye },
|
||||||
{ href: '/terminal/seo', label: 'Analyze SEO metrics', icon: Sparkles },
|
{ href: '/terminal/market', label: 'Browse the market', icon: Store },
|
||||||
{ href: '/terminal/alerts', label: 'Create Sniper Alerts', icon: Bell },
|
{ href: '/terminal/listing', label: 'List your domains', icon: Sparkles },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -241,8 +241,8 @@ export function useUserShortcuts() {
|
|||||||
// Navigation
|
// Navigation
|
||||||
{ key: 'g', label: 'Go to Dashboard', description: 'Navigate to dashboard', action: () => router.push('/terminal/radar'), category: '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: '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: 'p', label: 'Go to Watchlist', description: 'Navigate to watchlist', action: () => router.push('/terminal/watchlist'), category: 'navigation' },
|
||||||
{ key: 'a', label: 'Go to Auctions', description: 'Navigate to auctions', action: () => router.push('/terminal/auctions'), 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: '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' },
|
{ key: 's', label: 'Go to Settings', description: 'Navigate to settings', action: () => router.push('/terminal/settings'), category: 'navigation' },
|
||||||
// Actions
|
// Actions
|
||||||
|
|||||||
Reference in New Issue
Block a user