## Watchlist & Monitoring - ✅ Automatic domain monitoring based on subscription tier - ✅ Email alerts when domains become available - ✅ Health checks (DNS/HTTP/SSL) with caching - ✅ Expiry warnings for domains <30 days - ✅ Weekly digest emails - ✅ Instant alert toggle (optimistic UI updates) - ✅ Redesigned health check overlays with full details - 🔒 'Not public' display for .ch/.de domains without public expiry ## Portfolio Management (NEW) - ✅ Track owned domains with purchase price & date - ✅ ROI calculation (unrealized & realized) - ✅ Domain valuation with auto-refresh - ✅ Renewal date tracking - ✅ Sale recording with profit calculation - ✅ List domains for sale directly from portfolio - ✅ Full portfolio summary dashboard ## Listings / For Sale - ✅ Renamed from 'Portfolio' to 'For Sale' - ✅ Fixed listing limits: Scout=0, Trader=5, Tycoon=50 - ✅ Featured badge for Tycoon listings - ✅ Inquiries modal for sellers - ✅ Email notifications when buyer inquires - ✅ Inquiries column in listings table ## Scrapers & Data - ✅ Added 4 new registrar scrapers (Namecheap, Cloudflare, GoDaddy, Dynadot) - ✅ Increased scraping frequency to 2x daily (03:00 & 15:00 UTC) - ✅ Real historical data from database - ✅ Fixed RDAP/WHOIS for .ch/.de domains - ✅ Enhanced SSL certificate parsing ## Scheduler Jobs - ✅ Tiered domain checks (Scout=daily, Trader=hourly, Tycoon=10min) - ✅ Daily health checks (06:00 UTC) - ✅ Weekly expiry warnings (Mon 08:00 UTC) - ✅ Weekly digest emails (Sun 10:00 UTC) - ✅ Auction cleanup every 15 minutes ## UI/UX Improvements - ✅ Removed 'Back' buttons from Intel pages - ✅ Redesigned Radar page to match Market/Intel design - ✅ Less prominent check frequency footer - ✅ Consistent StatCard components across all pages - ✅ Ambient background glows - ✅ Better error handling ## Documentation - ✅ Updated README with monitoring section - ✅ Added env.example with all required variables - ✅ Updated Memory Bank (activeContext.md) - ✅ SMTP configuration requirements documented
1011 lines
43 KiB
TypeScript
Executable File
1011 lines
43 KiB
TypeScript
Executable File
'use client'
|
|
|
|
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
|
import { useStore } from '@/lib/store'
|
|
import { api, DomainHealthReport, HealthStatus } from '@/lib/api'
|
|
import { TerminalLayout } from '@/components/TerminalLayout'
|
|
import { Toast, useToast } from '@/components/Toast'
|
|
import {
|
|
Plus,
|
|
Trash2,
|
|
RefreshCw,
|
|
Loader2,
|
|
Bell,
|
|
BellOff,
|
|
Eye,
|
|
Sparkles,
|
|
ArrowUpRight,
|
|
X,
|
|
Activity,
|
|
Shield,
|
|
AlertTriangle,
|
|
ShoppingCart,
|
|
HelpCircle,
|
|
Search,
|
|
Globe,
|
|
Clock,
|
|
Calendar,
|
|
ArrowRight,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Wifi,
|
|
Lock,
|
|
TrendingDown,
|
|
Zap,
|
|
Diamond
|
|
} from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
import Link from 'next/link'
|
|
|
|
// ============================================================================
|
|
// SHARED COMPONENTS (Matched to Market/Intel/Radar)
|
|
// ============================================================================
|
|
|
|
const Tooltip = memo(({ children, content }: { children: React.ReactNode; content: string }) => (
|
|
<div className="relative flex items-center group/tooltip w-fit">
|
|
{children}
|
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-zinc-900 border border-zinc-800 rounded text-[10px] text-zinc-300 whitespace-nowrap opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none z-50 shadow-xl">
|
|
{content}
|
|
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-zinc-800" />
|
|
</div>
|
|
</div>
|
|
))
|
|
Tooltip.displayName = 'Tooltip'
|
|
|
|
const StatCard = memo(({
|
|
label,
|
|
value,
|
|
subValue,
|
|
icon: Icon,
|
|
highlight,
|
|
trend
|
|
}: {
|
|
label: string
|
|
value: string | number
|
|
subValue?: string
|
|
icon: any
|
|
highlight?: boolean
|
|
trend?: 'up' | 'down' | 'neutral' | 'active'
|
|
}) => (
|
|
<div className={clsx(
|
|
"bg-zinc-900/40 border p-4 relative overflow-hidden group hover:border-white/10 transition-colors h-full",
|
|
highlight ? "border-emerald-500/30" : "border-white/5"
|
|
)}>
|
|
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
|
<Icon className="w-16 h-16" />
|
|
</div>
|
|
<div className="relative z-10">
|
|
<div className="flex items-center gap-2 text-zinc-400 mb-1">
|
|
<Icon className={clsx("w-4 h-4", (highlight || trend === 'active' || trend === 'up') && "text-emerald-400")} />
|
|
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-2xl font-bold text-white tracking-tight">{value}</span>
|
|
{subValue && <span className="text-xs text-zinc-500 font-medium">{subValue}</span>}
|
|
</div>
|
|
{highlight && (
|
|
<div className="mt-2 text-[10px] font-medium px-1.5 py-0.5 w-fit rounded border text-emerald-400 border-emerald-400/20 bg-emerald-400/5">
|
|
● LIVE
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
StatCard.displayName = 'StatCard'
|
|
|
|
// Health status badge configuration
|
|
const healthStatusConfig: Record<HealthStatus, {
|
|
label: string
|
|
color: string
|
|
bgColor: string
|
|
icon: typeof Activity
|
|
description: string
|
|
}> = {
|
|
healthy: {
|
|
label: 'Online',
|
|
color: 'text-emerald-400',
|
|
bgColor: 'bg-emerald-500/10 border-emerald-500/20',
|
|
icon: Activity,
|
|
description: 'Domain is active and reachable',
|
|
},
|
|
weakening: {
|
|
label: 'Issues',
|
|
color: 'text-amber-400',
|
|
bgColor: 'bg-amber-500/10 border-amber-500/20',
|
|
icon: AlertTriangle,
|
|
description: 'Warning signs detected',
|
|
},
|
|
parked: {
|
|
label: 'Parked',
|
|
color: 'text-blue-400',
|
|
bgColor: 'bg-blue-500/10 border-blue-500/20',
|
|
icon: ShoppingCart,
|
|
description: 'Domain is parked/for sale',
|
|
},
|
|
critical: {
|
|
label: 'Critical',
|
|
color: 'text-rose-400',
|
|
bgColor: 'bg-rose-500/10 border-rose-500/20',
|
|
icon: AlertTriangle,
|
|
description: 'Domain may be dropping soon',
|
|
},
|
|
unknown: {
|
|
label: 'Unknown',
|
|
color: 'text-zinc-400',
|
|
bgColor: 'bg-zinc-800 border-zinc-700',
|
|
icon: HelpCircle,
|
|
description: 'Health check pending',
|
|
},
|
|
}
|
|
|
|
type FilterTab = 'all' | 'available' | 'expiring' | 'critical'
|
|
|
|
// ============================================================================
|
|
// HELPER FUNCTIONS
|
|
// ============================================================================
|
|
|
|
function getDaysUntilExpiry(expirationDate: string | null): number | null {
|
|
if (!expirationDate) return null
|
|
const expDate = new Date(expirationDate)
|
|
const now = new Date()
|
|
const diffTime = expDate.getTime() - now.getTime()
|
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
|
}
|
|
|
|
function formatExpiryDate(expirationDate: string | null): string {
|
|
if (!expirationDate) return '—'
|
|
return new Date(expirationDate).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
})
|
|
}
|
|
|
|
function getTimeAgo(date: string | null): string {
|
|
if (!date) return 'Never'
|
|
const now = new Date()
|
|
const past = new Date(date)
|
|
const diffMs = now.getTime() - past.getTime()
|
|
const diffMins = Math.floor(diffMs / 60000)
|
|
const diffHours = Math.floor(diffMs / 3600000)
|
|
const diffDays = Math.floor(diffMs / 86400000)
|
|
|
|
if (diffMins < 1) return 'Just now'
|
|
if (diffMins < 60) return `${diffMins}m ago`
|
|
if (diffHours < 24) return `${diffHours}h ago`
|
|
if (diffDays < 7) return `${diffDays}d ago`
|
|
return formatExpiryDate(date)
|
|
}
|
|
|
|
// ============================================================================
|
|
// MAIN PAGE
|
|
// ============================================================================
|
|
|
|
export default function WatchlistPage() {
|
|
const { domains, addDomain, deleteDomain, refreshDomain, updateDomain, subscription } = useStore()
|
|
const { toast, showToast, hideToast } = useToast()
|
|
|
|
const [newDomain, setNewDomain] = useState('')
|
|
const [adding, setAdding] = useState(false)
|
|
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
|
const [togglingNotifyId, setTogglingNotifyId] = useState<number | null>(null)
|
|
const [filterTab, setFilterTab] = useState<FilterTab>('all')
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
|
|
// Health check state
|
|
const [healthReports, setHealthReports] = useState<Record<number, DomainHealthReport>>({})
|
|
const [loadingHealth, setLoadingHealth] = useState<Record<number, boolean>>({})
|
|
const [selectedHealthDomainId, setSelectedHealthDomainId] = useState<number | null>(null)
|
|
|
|
|
|
// Memoized stats
|
|
const stats = useMemo(() => {
|
|
const available = domains?.filter(d => d.is_available) || []
|
|
const expiringSoon = domains?.filter(d => {
|
|
if (d.is_available || !d.expiration_date) return false
|
|
const days = getDaysUntilExpiry(d.expiration_date)
|
|
return days !== null && days <= 30 && days > 0
|
|
}) || []
|
|
const critical = Object.values(healthReports).filter(h => h.status === 'critical').length
|
|
|
|
return {
|
|
total: domains?.length || 0,
|
|
available: available.length,
|
|
expiringSoon: expiringSoon.length,
|
|
critical,
|
|
limit: subscription?.domain_limit || 5,
|
|
}
|
|
}, [domains, subscription?.domain_limit, healthReports])
|
|
|
|
const canAddMore = stats.total < stats.limit || stats.limit === -1
|
|
|
|
// Memoized filtered domains
|
|
const filteredDomains = useMemo(() => {
|
|
if (!domains) return []
|
|
|
|
return domains.filter(domain => {
|
|
// Search filter
|
|
if (searchQuery && !domain.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
|
return false
|
|
}
|
|
|
|
// Tab filter
|
|
switch (filterTab) {
|
|
case 'available':
|
|
return domain.is_available
|
|
case 'expiring':
|
|
if (domain.is_available || !domain.expiration_date) return false
|
|
const days = getDaysUntilExpiry(domain.expiration_date)
|
|
return days !== null && days <= 30 && days > 0
|
|
case 'critical':
|
|
const health = healthReports[domain.id]
|
|
return health?.status === 'critical' || health?.status === 'weakening'
|
|
default:
|
|
return true
|
|
}
|
|
}).sort((a, b) => {
|
|
// Sort available first, then by expiry date
|
|
if (a.is_available && !b.is_available) return -1
|
|
if (!a.is_available && b.is_available) return 1
|
|
|
|
// Then by expiry (soonest first)
|
|
const daysA = getDaysUntilExpiry(a.expiration_date)
|
|
const daysB = getDaysUntilExpiry(b.expiration_date)
|
|
if (daysA !== null && daysB !== null) return daysA - daysB
|
|
if (daysA !== null) return -1
|
|
if (daysB !== null) return 1
|
|
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
}, [domains, searchQuery, filterTab, healthReports])
|
|
|
|
// Callbacks
|
|
const handleAddDomain = useCallback(async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!newDomain.trim()) return
|
|
|
|
setAdding(true)
|
|
try {
|
|
await addDomain(newDomain.trim())
|
|
setNewDomain('')
|
|
showToast(`Added ${newDomain.trim()} to watchlist`, 'success')
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Failed to add domain', 'error')
|
|
} finally {
|
|
setAdding(false)
|
|
}
|
|
}, [newDomain, addDomain, showToast])
|
|
|
|
const handleRefresh = useCallback(async (id: number) => {
|
|
setRefreshingId(id)
|
|
try {
|
|
await refreshDomain(id)
|
|
showToast('Domain status refreshed', 'success')
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Failed to refresh', 'error')
|
|
} finally {
|
|
setRefreshingId(null)
|
|
}
|
|
}, [refreshDomain, showToast])
|
|
|
|
const handleDelete = useCallback(async (id: number, name: string) => {
|
|
if (!confirm(`Remove ${name} from your watchlist?`)) return
|
|
|
|
setDeletingId(id)
|
|
try {
|
|
await deleteDomain(id)
|
|
showToast(`Removed ${name} from watchlist`, 'success')
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Failed to remove', 'error')
|
|
} finally {
|
|
setDeletingId(null)
|
|
}
|
|
}, [deleteDomain, showToast])
|
|
|
|
const handleToggleNotify = useCallback(async (id: number, currentState: boolean) => {
|
|
setTogglingNotifyId(id)
|
|
try {
|
|
await api.updateDomainNotify(id, !currentState)
|
|
// Instant optimistic update
|
|
updateDomain(id, { notify_on_available: !currentState })
|
|
showToast(!currentState ? 'Notifications enabled' : 'Notifications disabled', 'success')
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Failed to update', 'error')
|
|
} finally {
|
|
setTogglingNotifyId(null)
|
|
}
|
|
}, [showToast, updateDomain])
|
|
|
|
const handleHealthCheck = useCallback(async (domainId: number) => {
|
|
if (loadingHealth[domainId]) return
|
|
|
|
setLoadingHealth(prev => ({ ...prev, [domainId]: true }))
|
|
try {
|
|
const report = await api.getDomainHealth(domainId)
|
|
setHealthReports(prev => ({ ...prev, [domainId]: report }))
|
|
setSelectedHealthDomainId(domainId)
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Health check failed', 'error')
|
|
} finally {
|
|
setLoadingHealth(prev => ({ ...prev, [domainId]: false }))
|
|
}
|
|
}, [loadingHealth, showToast])
|
|
|
|
|
|
// Load health data for all domains on mount
|
|
useEffect(() => {
|
|
const loadHealthData = async () => {
|
|
if (!domains || domains.length === 0) return
|
|
|
|
// Load health for registered domains only (not available ones)
|
|
const registeredDomains = domains.filter(d => !d.is_available)
|
|
|
|
for (const domain of registeredDomains.slice(0, 10)) { // Limit to first 10 to avoid overload
|
|
try {
|
|
const report = await api.getDomainHealth(domain.id)
|
|
setHealthReports(prev => ({ ...prev, [domain.id]: report }))
|
|
} catch {
|
|
// Silently fail - health data is optional
|
|
}
|
|
await new Promise(r => setTimeout(r, 200)) // Small delay
|
|
}
|
|
}
|
|
|
|
loadHealthData()
|
|
}, [domains])
|
|
|
|
return (
|
|
<TerminalLayout hideHeaderSearch={true}>
|
|
<div className="relative font-sans text-zinc-100 selection:bg-emerald-500/30">
|
|
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
|
|
|
{/* Ambient Background Glow */}
|
|
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
|
<div className="absolute top-0 right-1/4 w-[800px] h-[600px] bg-emerald-500/5 rounded-full blur-[120px] mix-blend-screen" />
|
|
<div className="absolute bottom-0 left-1/4 w-[600px] h-[500px] bg-blue-500/5 rounded-full blur-[100px] mix-blend-screen" />
|
|
</div>
|
|
|
|
<div className="relative z-10 max-w-[1600px] mx-auto p-4 md:p-8 space-y-8">
|
|
|
|
{/* Header Section */}
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 border-b border-white/5 pb-8">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-8 w-1 bg-emerald-500 rounded-full shadow-[0_0_10px_rgba(16,185,129,0.5)]" />
|
|
<h1 className="text-3xl font-bold tracking-tight text-white">Watchlist</h1>
|
|
</div>
|
|
<p className="text-zinc-400 max-w-lg">
|
|
Monitor availability, health, and expiration dates for your tracked domains.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Quick Stats Pills */}
|
|
<div className="flex gap-2">
|
|
{stats.available > 0 && (
|
|
<div className="px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center gap-2 text-xs font-medium text-emerald-400 animate-pulse">
|
|
<Sparkles className="w-3.5 h-3.5" />
|
|
{stats.available} Available!
|
|
</div>
|
|
)}
|
|
<div className="px-3 py-1.5 rounded-full bg-white/5 border border-white/10 flex items-center gap-2 text-xs font-medium text-zinc-300">
|
|
<Activity className="w-3.5 h-3.5 text-blue-400" />
|
|
Auto-Monitoring
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metric Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<StatCard
|
|
label="Total Tracked"
|
|
value={stats.total}
|
|
subValue={stats.limit === -1 ? '/ ∞' : `/ ${stats.limit}`}
|
|
icon={Eye}
|
|
highlight={stats.available > 0}
|
|
trend={stats.available > 0 ? 'up' : 'active'}
|
|
/>
|
|
<StatCard
|
|
label="Available Now"
|
|
value={stats.available}
|
|
subValue="Ready to buy"
|
|
icon={Sparkles}
|
|
trend={stats.available > 0 ? 'up' : 'neutral'}
|
|
/>
|
|
<StatCard
|
|
label="Expiring Soon"
|
|
value={stats.expiringSoon}
|
|
subValue="< 30 days"
|
|
icon={Clock}
|
|
trend={stats.expiringSoon > 0 ? 'down' : 'neutral'}
|
|
/>
|
|
<StatCard
|
|
label="Health Issues"
|
|
value={stats.critical}
|
|
subValue="Need attention"
|
|
icon={AlertTriangle}
|
|
trend={stats.critical > 0 ? 'down' : 'neutral'}
|
|
/>
|
|
</div>
|
|
|
|
{/* Control Bar */}
|
|
<div className="sticky top-4 z-30 bg-black/80 backdrop-blur-md border border-white/10 rounded-xl p-2 flex flex-col md:flex-row gap-4 items-center justify-between shadow-2xl">
|
|
{/* Filter Tabs */}
|
|
<div className="flex items-center gap-1 bg-white/5 p-1 rounded-lg">
|
|
{([
|
|
{ key: 'all', label: 'All', count: stats.total },
|
|
{ key: 'available', label: 'Available', count: stats.available },
|
|
{ key: 'expiring', label: 'Expiring', count: stats.expiringSoon },
|
|
{ key: 'critical', label: 'Issues', count: stats.critical },
|
|
] as const).map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setFilterTab(tab.key)}
|
|
className={clsx(
|
|
"px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2",
|
|
filterTab === tab.key
|
|
? "bg-zinc-800 text-white shadow-sm"
|
|
: "text-zinc-400 hover:text-zinc-200 hover:bg-white/5"
|
|
)}
|
|
>
|
|
{tab.label}
|
|
{tab.count > 0 && (
|
|
<span className={clsx(
|
|
"px-1.5 py-0.5 rounded text-[10px] font-bold",
|
|
filterTab === tab.key
|
|
? "bg-white/10"
|
|
: "bg-zinc-800"
|
|
)}>
|
|
{tab.count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Add Domain Input */}
|
|
<form onSubmit={handleAddDomain} className="flex-1 max-w-md w-full relative group">
|
|
<input
|
|
type="text"
|
|
value={newDomain}
|
|
onChange={(e) => setNewDomain(e.target.value)}
|
|
placeholder="Add domain (e.g. apple.com)..."
|
|
className="w-full bg-black/50 border border-white/10 rounded-lg pl-10 pr-12 py-2 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all"
|
|
/>
|
|
<Plus className="absolute left-3 top-2.5 w-4 h-4 text-zinc-500 group-focus-within:text-emerald-500 transition-colors" />
|
|
<button
|
|
type="submit"
|
|
disabled={adding || !newDomain.trim() || !canAddMore}
|
|
className="absolute right-2 top-1.5 p-1 hover:bg-emerald-500/20 rounded text-zinc-500 hover:text-emerald-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{adding ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUpRight className="w-4 h-4" />}
|
|
</button>
|
|
</form>
|
|
|
|
{/* Search Filter */}
|
|
<div className="relative w-full md:w-64">
|
|
<Search className="absolute left-3 top-2.5 w-4 h-4 text-zinc-500" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Filter domains..."
|
|
className="w-full bg-black/50 border border-white/10 rounded-lg pl-9 pr-4 py-2 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-white/20 transition-all"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Limit Warning */}
|
|
{!canAddMore && (
|
|
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-sm text-amber-400">
|
|
<AlertTriangle className="w-4 h-4" />
|
|
<span>Limit reached ({stats.total}/{stats.limit}). Upgrade to track more.</span>
|
|
</div>
|
|
<Link href="/pricing" className="text-xs font-bold text-amber-400 hover:text-amber-300 flex items-center gap-1 uppercase tracking-wide">
|
|
Upgrade <ArrowUpRight className="w-3 h-3" />
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{/* Data Grid */}
|
|
<div className="bg-zinc-900/40 border border-white/5 rounded-xl overflow-hidden backdrop-blur-sm">
|
|
{/* Table Header */}
|
|
<div className="overflow-x-auto">
|
|
<div className="min-w-[900px]">
|
|
<div className="grid grid-cols-12 gap-4 px-6 py-3 bg-white/[0.02] border-b border-white/5 text-[11px] font-semibold text-zinc-500 uppercase tracking-wider">
|
|
<div className="col-span-3">Domain</div>
|
|
<div className="col-span-2 text-center">Status</div>
|
|
<div className="col-span-2 text-center">Health</div>
|
|
<div className="col-span-2 text-center">Expires</div>
|
|
<div className="col-span-1 text-center">Alerts</div>
|
|
<div className="col-span-2 text-right">Actions</div>
|
|
</div>
|
|
|
|
{filteredDomains.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mb-4">
|
|
<Eye className="w-8 h-8 text-zinc-600" />
|
|
</div>
|
|
<h3 className="text-lg font-medium text-white mb-1">
|
|
{searchQuery ? "No matches found" : filterTab !== 'all' ? `No ${filterTab} domains` : "Watchlist is empty"}
|
|
</h3>
|
|
<p className="text-zinc-500 text-sm max-w-xs mx-auto mb-6">
|
|
{searchQuery ? "Try adjusting your filter." : "Start by adding domains you want to track."}
|
|
</p>
|
|
{!searchQuery && filterTab === 'all' && (
|
|
<button
|
|
onClick={() => document.querySelector('input')?.focus()}
|
|
className="text-emerald-400 text-sm hover:text-emerald-300 transition-colors flex items-center gap-2"
|
|
>
|
|
Add first domain <ArrowRight className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-white/5">
|
|
{filteredDomains.map((domain) => {
|
|
const health = healthReports[domain.id]
|
|
const healthConfig = health ? healthStatusConfig[health.status] : healthStatusConfig.unknown
|
|
const daysUntilExpiry = getDaysUntilExpiry(domain.expiration_date)
|
|
const isExpiringSoon = daysUntilExpiry !== null && daysUntilExpiry <= 30 && daysUntilExpiry > 0
|
|
const isExpired = daysUntilExpiry !== null && daysUntilExpiry <= 0
|
|
|
|
return (
|
|
<div key={domain.id} className="grid grid-cols-12 gap-4 px-6 py-4 items-center hover:bg-white/[0.04] transition-all group relative">
|
|
|
|
{/* Domain */}
|
|
<div className="col-span-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative">
|
|
<div className={clsx(
|
|
"w-2.5 h-2.5 rounded-full",
|
|
domain.is_available
|
|
? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]"
|
|
: healthConfig.color.replace('text-', 'bg-').replace('-400', '-500')
|
|
)} />
|
|
{domain.is_available && (
|
|
<div className="absolute inset-0 bg-emerald-500 rounded-full animate-ping opacity-50" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<span className="font-mono font-bold text-white text-[15px] tracking-tight">{domain.name}</span>
|
|
{domain.registrar && (
|
|
<p className="text-[10px] text-zinc-600 mt-0.5 truncate max-w-[180px]">
|
|
{domain.registrar}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status */}
|
|
<div className="col-span-2 flex justify-center">
|
|
{domain.is_available ? (
|
|
<span className="flex items-center gap-1.5 text-[11px] font-bold px-2.5 py-1 rounded border bg-emerald-500/10 text-emerald-400 border-emerald-500/20 uppercase tracking-wider">
|
|
<CheckCircle2 className="w-3 h-3" />
|
|
Available
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1.5 text-[11px] font-medium px-2.5 py-1 rounded border bg-zinc-800 text-zinc-400 border-zinc-700 uppercase tracking-wider">
|
|
<XCircle className="w-3 h-3" />
|
|
Registered
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Health */}
|
|
<div className="col-span-2 flex justify-center">
|
|
{domain.is_available ? (
|
|
<span className="text-xs text-zinc-600">—</span>
|
|
) : health ? (
|
|
<Tooltip content={healthConfig.description}>
|
|
<button
|
|
onClick={() => setSelectedHealthDomainId(domain.id)}
|
|
className={clsx(
|
|
"flex items-center gap-1.5 px-2 py-1 rounded border text-xs font-medium transition-colors hover:bg-white/5",
|
|
healthConfig.bgColor,
|
|
healthConfig.color
|
|
)}
|
|
>
|
|
<healthConfig.icon className="w-3 h-3" />
|
|
{healthConfig.label}
|
|
</button>
|
|
</Tooltip>
|
|
) : (
|
|
<Tooltip content="Click to check health">
|
|
<button
|
|
onClick={() => handleHealthCheck(domain.id)}
|
|
disabled={loadingHealth[domain.id]}
|
|
className="text-zinc-600 hover:text-zinc-400 transition-colors p-1"
|
|
>
|
|
{loadingHealth[domain.id] ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Activity className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
|
|
{/* Expiry */}
|
|
<div className="col-span-2 flex justify-center">
|
|
{domain.is_available ? (
|
|
<span className="text-xs text-zinc-600">—</span>
|
|
) : domain.expiration_date ? (
|
|
<div className={clsx(
|
|
"text-center",
|
|
isExpired ? "text-rose-400" : isExpiringSoon ? "text-amber-400" : "text-zinc-400"
|
|
)}>
|
|
<p className="text-xs font-mono font-medium">
|
|
{formatExpiryDate(domain.expiration_date)}
|
|
</p>
|
|
{daysUntilExpiry !== null && (
|
|
<p className={clsx(
|
|
"text-[10px] mt-0.5",
|
|
isExpired ? "text-rose-500 font-bold" : isExpiringSoon ? "text-amber-500" : "text-zinc-600"
|
|
)}>
|
|
{isExpired ? 'EXPIRED' : `${daysUntilExpiry}d left`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Tooltip content="Some registries like .ch and .de don't publish expiration dates publicly">
|
|
<span className="text-[10px] text-zinc-600 flex items-center gap-1 cursor-help">
|
|
<Lock className="w-3 h-3" />
|
|
Not public
|
|
</span>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
|
|
{/* Alerts */}
|
|
<div className="col-span-1 flex justify-center">
|
|
<Tooltip content={domain.notify_on_available ? "Alerts enabled" : "Alerts disabled"}>
|
|
<button
|
|
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
|
|
disabled={togglingNotifyId === domain.id}
|
|
className={clsx(
|
|
"p-1.5 rounded-lg transition-all",
|
|
domain.notify_on_available
|
|
? "text-emerald-400 bg-emerald-400/10 hover:bg-emerald-400/20"
|
|
: "text-zinc-600 hover:text-zinc-400 hover:bg-white/5"
|
|
)}
|
|
>
|
|
{togglingNotifyId === domain.id ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : domain.notify_on_available ? (
|
|
<Bell className="w-4 h-4" />
|
|
) : (
|
|
<BellOff className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="col-span-2 flex justify-end items-center gap-2">
|
|
<Tooltip content={`Last checked: ${getTimeAgo(domain.last_checked)}`}>
|
|
<button
|
|
onClick={() => handleRefresh(domain.id)}
|
|
disabled={refreshingId === domain.id}
|
|
className={clsx(
|
|
"p-1.5 rounded-lg text-zinc-500 hover:text-white hover:bg-white/10 transition-colors",
|
|
refreshingId === domain.id && "animate-spin text-emerald-400"
|
|
)}
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
|
|
<Tooltip content="Remove">
|
|
<button
|
|
onClick={() => handleDelete(domain.id, domain.name)}
|
|
disabled={deletingId === domain.id}
|
|
className="p-1.5 rounded-lg text-zinc-500 hover:text-rose-400 hover:bg-rose-400/10 transition-colors"
|
|
>
|
|
{deletingId === domain.id ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</Tooltip>
|
|
|
|
{domain.is_available && (
|
|
<a
|
|
href={`https://www.namecheap.com/domains/registration/results/?domain=${domain.name}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="ml-2 flex items-center gap-1.5 px-4 py-1.5 bg-emerald-500 text-white text-[11px] font-bold uppercase tracking-wider rounded-lg hover:bg-emerald-400 transition-colors shadow-lg shadow-emerald-500/20"
|
|
>
|
|
Buy <ArrowRight className="w-3 h-3" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Subtle Footer Info */}
|
|
<div className="flex items-center justify-center gap-4 text-[10px] text-zinc-700 py-3">
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
Checks: {subscription?.tier === 'tycoon' ? '10min' : subscription?.tier === 'trader' ? 'hourly' : 'daily'}
|
|
</span>
|
|
{subscription?.tier !== 'tycoon' && (
|
|
<Link href="/pricing" className="text-zinc-500 hover:text-zinc-400 transition-colors">
|
|
Upgrade
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Health Report Modal */}
|
|
{selectedHealthDomainId && healthReports[selectedHealthDomainId] && (
|
|
<HealthReportModal
|
|
report={healthReports[selectedHealthDomainId]}
|
|
onClose={() => setSelectedHealthDomainId(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</TerminalLayout>
|
|
)
|
|
}
|
|
|
|
// Health Report Modal Component
|
|
const HealthReportModal = memo(function HealthReportModal({
|
|
report,
|
|
onClose
|
|
}: {
|
|
report: DomainHealthReport
|
|
onClose: () => void
|
|
}) {
|
|
const config = healthStatusConfig[report.status]
|
|
const Icon = config.icon
|
|
|
|
// Safely access nested properties
|
|
const dns = report.dns || {}
|
|
const http = report.http || {}
|
|
const ssl = report.ssl || {}
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="w-full max-w-lg bg-[#0A0A0A] border border-white/10 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 fade-in duration-200"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-5 border-b border-white/5 bg-white/[0.02]">
|
|
<div className="flex items-center gap-3">
|
|
<div className={clsx("p-2 rounded-lg border", config.bgColor)}>
|
|
<Icon className={clsx("w-5 h-5", config.color)} />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-mono font-bold text-lg text-white tracking-tight">{report.domain}</h3>
|
|
<p className="text-xs text-zinc-500">{config.description}</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full text-zinc-500 hover:text-white transition-colors">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Score */}
|
|
<div className="p-6 border-b border-white/5">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-xs font-medium text-zinc-500 uppercase tracking-wider">Health Score</span>
|
|
<span className={clsx(
|
|
"text-2xl font-bold tabular-nums",
|
|
report.score >= 70 ? "text-emerald-400" :
|
|
report.score >= 40 ? "text-amber-400" : "text-rose-400"
|
|
)}>
|
|
{report.score}/100
|
|
</span>
|
|
</div>
|
|
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
|
|
<div
|
|
className={clsx(
|
|
"h-full rounded-full transition-all duration-1000",
|
|
report.score >= 70 ? "bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.5)]" :
|
|
report.score >= 40 ? "bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.5)]" : "bg-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.5)]"
|
|
)}
|
|
style={{ width: `${report.score}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Check Results */}
|
|
<div className="p-6 space-y-5 max-h-[400px] overflow-y-auto">
|
|
|
|
{/* Infrastructure */}
|
|
<div>
|
|
<h4 className="text-xs font-bold text-zinc-400 uppercase tracking-widest mb-3 flex items-center gap-2">
|
|
<Globe className="w-3 h-3" /> Infrastructure
|
|
</h4>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{/* DNS */}
|
|
<div className="bg-white/5 border border-white/5 rounded-lg p-3">
|
|
<div className="text-[10px] text-zinc-500 uppercase mb-1">DNS Status</div>
|
|
<div className={clsx(
|
|
"text-sm font-medium",
|
|
dns.has_ns ? "text-emerald-400" : "text-rose-400"
|
|
)}>
|
|
{dns.has_ns ? '● Active' : '○ No Records'}
|
|
</div>
|
|
{dns.nameservers && dns.nameservers.length > 0 && (
|
|
<p className="text-[10px] text-zinc-600 mt-1 truncate">
|
|
{dns.nameservers[0]}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Web Server */}
|
|
<div className="bg-white/5 border border-white/5 rounded-lg p-3">
|
|
<div className="text-[10px] text-zinc-500 uppercase mb-1">Web Server</div>
|
|
<div className={clsx(
|
|
"text-sm font-medium",
|
|
http.is_reachable ? "text-emerald-400" : "text-rose-400"
|
|
)}>
|
|
{http.is_reachable
|
|
? `● HTTP ${http.status_code || 200}`
|
|
: http.error
|
|
? `○ ${http.error}`
|
|
: '○ Unreachable'
|
|
}
|
|
</div>
|
|
{http.content_length !== undefined && http.content_length > 0 && (
|
|
<p className="text-[10px] text-zinc-600 mt-1">
|
|
{(http.content_length / 1024).toFixed(1)} KB
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* A Record */}
|
|
<div className="bg-white/5 border border-white/5 rounded-lg p-3">
|
|
<div className="text-[10px] text-zinc-500 uppercase mb-1">A Record</div>
|
|
<div className={clsx(
|
|
"text-sm font-medium",
|
|
dns.has_a ? "text-emerald-400" : "text-zinc-500"
|
|
)}>
|
|
{dns.has_a ? '● Configured' : '○ Not set'}
|
|
</div>
|
|
</div>
|
|
|
|
{/* MX Record */}
|
|
<div className="bg-white/5 border border-white/5 rounded-lg p-3">
|
|
<div className="text-[10px] text-zinc-500 uppercase mb-1">Mail (MX)</div>
|
|
<div className={clsx(
|
|
"text-sm font-medium",
|
|
dns.has_mx ? "text-emerald-400" : "text-zinc-500"
|
|
)}>
|
|
{dns.has_mx ? '● Configured' : '○ Not set'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Security */}
|
|
<div>
|
|
<h4 className="text-xs font-bold text-zinc-400 uppercase tracking-widest mb-3 flex items-center gap-2">
|
|
<Shield className="w-3 h-3" /> Security
|
|
</h4>
|
|
<div className="bg-white/5 border border-white/5 rounded-lg p-4 space-y-3">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm font-medium text-white">SSL Certificate</span>
|
|
<span className={clsx(
|
|
"text-xs px-2 py-0.5 rounded border font-bold uppercase tracking-wider",
|
|
ssl.has_certificate && ssl.is_valid
|
|
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
|
|
: ssl.has_certificate && !ssl.is_valid
|
|
? "bg-amber-500/10 text-amber-400 border-amber-500/20"
|
|
: "bg-rose-500/10 text-rose-400 border-rose-500/20"
|
|
)}>
|
|
{ssl.has_certificate && ssl.is_valid ? 'Secure' :
|
|
ssl.has_certificate ? 'Invalid' : 'None'}
|
|
</span>
|
|
</div>
|
|
|
|
{ssl.issuer && (
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-zinc-500">Issuer</span>
|
|
<span className="text-zinc-300 font-medium">{ssl.issuer}</span>
|
|
</div>
|
|
)}
|
|
|
|
{ssl.days_until_expiry !== undefined && ssl.days_until_expiry !== null && (
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-zinc-500">Expires in</span>
|
|
<span className={clsx(
|
|
"font-mono font-medium",
|
|
ssl.days_until_expiry <= 7 ? "text-rose-400" :
|
|
ssl.days_until_expiry <= 30 ? "text-amber-400" : "text-zinc-300"
|
|
)}>
|
|
{ssl.days_until_expiry} days
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{ssl.error && (
|
|
<p className="text-[10px] text-rose-400 bg-rose-500/5 px-2 py-1 rounded border border-rose-500/10">
|
|
{ssl.error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Parking Detection */}
|
|
{(dns.is_parked || http.is_parked) && (
|
|
<div>
|
|
<h4 className="text-xs font-bold text-amber-400 uppercase tracking-widest mb-3 flex items-center gap-2">
|
|
<ShoppingCart className="w-3 h-3" /> Parking Detected
|
|
</h4>
|
|
<div className="bg-amber-500/5 border border-amber-500/20 rounded-lg p-3">
|
|
<p className="text-xs text-amber-300">
|
|
This domain appears to be parked or for sale.
|
|
{dns.parking_provider && (
|
|
<span className="block mt-1 text-zinc-400">Provider: {dns.parking_provider}</span>
|
|
)}
|
|
</p>
|
|
{http.parking_keywords && http.parking_keywords.length > 0 && (
|
|
<p className="text-[10px] text-zinc-500 mt-2">
|
|
Keywords: {http.parking_keywords.slice(0, 3).join(', ')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Signals */}
|
|
{report.signals && report.signals.length > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-bold text-zinc-400 uppercase tracking-widest mb-3 flex items-center gap-2">
|
|
<Activity className="w-3 h-3" /> Signals
|
|
</h4>
|
|
<ul className="space-y-2">
|
|
{report.signals.map((signal, i) => (
|
|
<li key={i} className="text-xs text-zinc-300 flex items-start gap-2 bg-white/[0.02] p-2 rounded border border-white/5">
|
|
{signal}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recommendations */}
|
|
{report.recommendations && report.recommendations.length > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-bold text-blue-400 uppercase tracking-widest mb-3 flex items-center gap-2">
|
|
<Zap className="w-3 h-3" /> Recommendations
|
|
</h4>
|
|
<ul className="space-y-2">
|
|
{report.recommendations.map((rec, i) => (
|
|
<li key={i} className="text-xs text-blue-300 flex items-start gap-2 bg-blue-500/5 p-2 rounded border border-blue-500/10">
|
|
{rec}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 bg-zinc-950 border-t border-white/5">
|
|
<p className="text-[10px] text-zinc-600 text-center font-mono">
|
|
LAST CHECK: {new Date(report.checked_at).toLocaleString().toUpperCase()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|