'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 }) => (
))
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'
}) => (
{label}
{value}
{subValue && {subValue}}
{highlight && (
● LIVE
)}
))
StatCard.displayName = 'StatCard'
// Health status badge configuration
const healthStatusConfig: Record = {
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(null)
const [deletingId, setDeletingId] = useState(null)
const [togglingNotifyId, setTogglingNotifyId] = useState(null)
const [filterTab, setFilterTab] = useState('all')
const [searchQuery, setSearchQuery] = useState('')
// Health check state
const [healthReports, setHealthReports] = useState>({})
const [loadingHealth, setLoadingHealth] = useState>({})
const [selectedHealthDomainId, setSelectedHealthDomainId] = useState(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 (
{toast &&
}
{/* Ambient Background Glow */}
{/* Header Section */}
Monitor availability, health, and expiration dates for your tracked domains.
{/* Quick Stats Pills */}
{stats.available > 0 && (
{stats.available} Available!
)}
{/* Metric Grid */}
0}
trend={stats.available > 0 ? 'up' : 'active'}
/>
0 ? 'up' : 'neutral'}
/>
0 ? 'down' : 'neutral'}
/>
0 ? 'down' : 'neutral'}
/>
{/* Control Bar */}
{/* Filter Tabs */}
{([
{ 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) => (
))}
{/* Add Domain Input */}
{/* Search Filter */}
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"
/>
{/* Limit Warning */}
{!canAddMore && (
Limit reached ({stats.total}/{stats.limit}). Upgrade to track more.
Upgrade
)}
{/* Data Grid */}
{/* Table Header */}
Domain
Status
Health
Expires
Alerts
Actions
{filteredDomains.length === 0 ? (
{searchQuery ? "No matches found" : filterTab !== 'all' ? `No ${filterTab} domains` : "Watchlist is empty"}
{searchQuery ? "Try adjusting your filter." : "Start by adding domains you want to track."}
{!searchQuery && filterTab === 'all' && (
)}
) : (
{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 (
{/* Domain */}
{domain.is_available && (
)}
{domain.name}
{domain.registrar && (
{domain.registrar}
)}
{/* Status */}
{domain.is_available ? (
Available
) : (
Registered
)}
{/* Health */}
{domain.is_available ? (
—
) : health ? (
) : (
)}
{/* Expiry */}
{domain.is_available ? (
—
) : domain.expiration_date ? (
{formatExpiryDate(domain.expiration_date)}
{daysUntilExpiry !== null && (
{isExpired ? 'EXPIRED' : `${daysUntilExpiry}d left`}
)}
) : (
Not public
)}
{/* Alerts */}
{/* Actions */}
{domain.is_available && (
Buy
)}
)
})}
)}
{/* Subtle Footer Info */}
Checks: {subscription?.tier === 'tycoon' ? '10min' : subscription?.tier === 'trader' ? 'hourly' : 'daily'}
{subscription?.tier !== 'tycoon' && (
Upgrade
)}
{/* Health Report Modal */}
{selectedHealthDomainId && healthReports[selectedHealthDomainId] && (
setSelectedHealthDomainId(null)}
/>
)}
)
}
// 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 (
e.stopPropagation()}
>
{/* Header */}
{report.domain}
{config.description}
{/* Score */}
Health Score
= 70 ? "text-emerald-400" :
report.score >= 40 ? "text-amber-400" : "text-rose-400"
)}>
{report.score}/100
= 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}%` }}
/>
{/* Check Results */}
{/* Infrastructure */}
Infrastructure
{/* DNS */}
DNS Status
{dns.has_ns ? '● Active' : '○ No Records'}
{dns.nameservers && dns.nameservers.length > 0 && (
{dns.nameservers[0]}
)}
{/* Web Server */}
Web Server
{http.is_reachable
? `● HTTP ${http.status_code || 200}`
: http.error
? `○ ${http.error}`
: '○ Unreachable'
}
{http.content_length !== undefined && http.content_length > 0 && (
{(http.content_length / 1024).toFixed(1)} KB
)}
{/* A Record */}
A Record
{dns.has_a ? '● Configured' : '○ Not set'}
{/* MX Record */}
Mail (MX)
{dns.has_mx ? '● Configured' : '○ Not set'}
{/* Security */}
Security
SSL Certificate
{ssl.has_certificate && ssl.is_valid ? 'Secure' :
ssl.has_certificate ? 'Invalid' : 'None'}
{ssl.issuer && (
Issuer
{ssl.issuer}
)}
{ssl.days_until_expiry !== undefined && ssl.days_until_expiry !== null && (
Expires in
{ssl.days_until_expiry} days
)}
{ssl.error && (
{ssl.error}
)}
{/* Parking Detection */}
{(dns.is_parked || http.is_parked) && (
Parking Detected
This domain appears to be parked or for sale.
{dns.parking_provider && (
Provider: {dns.parking_provider}
)}
{http.parking_keywords && http.parking_keywords.length > 0 && (
Keywords: {http.parking_keywords.slice(0, 3).join(', ')}
)}
)}
{/* Signals */}
{report.signals && report.signals.length > 0 && (
Signals
{report.signals.map((signal, i) => (
-
{signal}
))}
)}
{/* Recommendations */}
{report.recommendations && report.recommendations.length > 0 && (
Recommendations
{report.recommendations.map((rec, i) => (
-
{rec}
))}
)}
{/* Footer */}
LAST CHECK: {new Date(report.checked_at).toLocaleString().toUpperCase()}
)
})