yves.gugger dc77b2110a feat: Complete Watchlist monitoring, Portfolio tracking & Listings marketplace
## 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
2025-12-11 16:57:28 +01:00

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>
)
})