feat: Comprehensive Command Center improvements
PAGE HEADERS: - All pages now show dynamic, context-aware subtitles - Dashboard: Time-based greeting + status summary - Watchlist: Domain count + slots remaining - Portfolio: Profit/loss summary - Auctions: Live count across platforms - Intelligence: TLD count or loading state TABLE IMPROVEMENTS: - Fixed column alignments with table-fixed layout - Added width constraints for consistent column sizing - Better header button alignment for sortable columns - tabular-nums for numeric values DOMAIN HEALTH INTEGRATION: - Added getDomainHealth and quickHealthCheck API methods - Health status types (healthy, weakening, parked, critical) - Health check button in watchlist with modal view - 4-layer analysis display (DNS, HTTP, SSL, recommendations) - Optional chaining to prevent undefined errors UI FIXES: - Consistent card and section styling - Improved filter/tab buttons - Better empty states - Search with clear button
This commit is contained in:
@ -193,10 +193,18 @@ export default function AuctionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic subtitle
|
||||
const getSubtitle = () => {
|
||||
if (loading) return 'Loading live auctions...'
|
||||
const total = allAuctions.length
|
||||
if (total === 0) return 'No active auctions found'
|
||||
return `${total.toLocaleString()} live auctions across 4 platforms`
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="Auctions"
|
||||
subtitle="Real-time from GoDaddy, Sedo, NameJet & DropCatch"
|
||||
subtitle={getSubtitle()}
|
||||
actions={
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
|
||||
@ -121,10 +121,29 @@ export default function DashboardPage() {
|
||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||
const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap
|
||||
|
||||
// Dynamic greeting based on time
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Good morning'
|
||||
if (hour < 18) return 'Good afternoon'
|
||||
return 'Good evening'
|
||||
}
|
||||
|
||||
// Dynamic subtitle
|
||||
const getSubtitle = () => {
|
||||
if (availableDomains.length > 0) {
|
||||
return `🎯 ${availableDomains.length} domain${availableDomains.length !== 1 ? 's' : ''} ready to pounce!`
|
||||
}
|
||||
if (totalDomains > 0) {
|
||||
return `Monitoring ${totalDomains} domain${totalDomains !== 1 ? 's' : ''} for you`
|
||||
}
|
||||
return 'Start tracking domains to find opportunities'
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title={`Welcome back${user?.name ? `, ${user.name.split(' ')[0]}` : ''}`}
|
||||
subtitle="Your domain command center"
|
||||
title={`${getGreeting()}${user?.name ? `, ${user.name.split(' ')[0]}` : ''}`}
|
||||
subtitle={getSubtitle()}
|
||||
>
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
BarChart3,
|
||||
RefreshCw,
|
||||
Bell,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
@ -76,19 +77,28 @@ export default function IntelligencePage() {
|
||||
)
|
||||
|
||||
const getTrendIcon = (change: number | undefined) => {
|
||||
if (!change) return <Minus className="w-4 h-4 text-foreground-muted" />
|
||||
if (!change || change === 0) return <Minus className="w-4 h-4 text-foreground-muted" />
|
||||
if (change > 0) return <TrendingUp className="w-4 h-4 text-orange-400" />
|
||||
return <TrendingDown className="w-4 h-4 text-accent" />
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const lowestPrice = tldData.reduce((min, tld) => Math.min(min, tld.min_price), Infinity)
|
||||
const hottestTld = tldData.find(tld => (tld.price_change_7d || 0) > 0)?.tld || 'N/A'
|
||||
const lowestPrice = tldData.length > 0
|
||||
? tldData.reduce((min, tld) => Math.min(min, tld.min_price), Infinity)
|
||||
: 0.99
|
||||
const hottestTld = tldData.find(tld => (tld.price_change_7d || 0) > 0)?.tld || 'com'
|
||||
|
||||
// Dynamic subtitle
|
||||
const getSubtitle = () => {
|
||||
if (loading && total === 0) return 'Loading TLD pricing data...'
|
||||
if (total === 0) return 'No TLD data available'
|
||||
return `Comparing prices across ${total.toLocaleString()} TLDs`
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="TLD Intelligence"
|
||||
subtitle={`Real-time pricing data for ${total}+ TLDs`}
|
||||
subtitle={getSubtitle()}
|
||||
actions={
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
@ -104,10 +114,30 @@ export default function IntelligencePage() {
|
||||
<PageContainer>
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="TLDs Tracked" value={total} subtitle="updated daily" icon={Globe} accent />
|
||||
<StatCard title="Lowest Price" value={`$${lowestPrice === Infinity ? '0.99' : lowestPrice.toFixed(2)}`} icon={DollarSign} />
|
||||
<StatCard title="Hottest TLD" value={`.${hottestTld}`} subtitle="rising prices" icon={TrendingUp} />
|
||||
<StatCard title="Update Freq" value="24h" subtitle="automatic" icon={BarChart3} />
|
||||
<StatCard
|
||||
title="TLDs Tracked"
|
||||
value={total > 0 ? total.toLocaleString() : '—'}
|
||||
subtitle="updated daily"
|
||||
icon={Globe}
|
||||
accent
|
||||
/>
|
||||
<StatCard
|
||||
title="Lowest Price"
|
||||
value={total > 0 ? `$${lowestPrice.toFixed(2)}` : '—'}
|
||||
icon={DollarSign}
|
||||
/>
|
||||
<StatCard
|
||||
title="Hottest TLD"
|
||||
value={total > 0 ? `.${hottestTld}` : '—'}
|
||||
subtitle="rising prices"
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
<StatCard
|
||||
title="Update Freq"
|
||||
value="24h"
|
||||
subtitle="automatic"
|
||||
icon={BarChart3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@ -118,11 +148,19 @@ export default function IntelligencePage() {
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search TLDs..."
|
||||
className="w-full h-11 pl-11 pr-4 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||
placeholder="Search TLDs (e.g. com, io, dev)..."
|
||||
className="w-full h-11 pl-11 pr-10 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||
text-sm text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-accent/50 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
@ -133,9 +171,9 @@ export default function IntelligencePage() {
|
||||
focus:outline-none focus:border-accent/50"
|
||||
>
|
||||
<option value="popularity">By Popularity</option>
|
||||
<option value="price_asc">Price: Low to High</option>
|
||||
<option value="price_desc">Price: High to Low</option>
|
||||
<option value="change">By Change %</option>
|
||||
<option value="price_asc">Price: Low → High</option>
|
||||
<option value="price_desc">Price: High → Low</option>
|
||||
<option value="change">By Price Change</option>
|
||||
</select>
|
||||
<ArrowUpDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted pointer-events-none" />
|
||||
</div>
|
||||
@ -154,8 +192,9 @@ export default function IntelligencePage() {
|
||||
{
|
||||
key: 'tld',
|
||||
header: 'TLD',
|
||||
width: '120px',
|
||||
render: (tld) => (
|
||||
<span className="font-mono text-xl font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||
.{tld.tld}
|
||||
</span>
|
||||
),
|
||||
@ -163,26 +202,32 @@ export default function IntelligencePage() {
|
||||
{
|
||||
key: 'min_price',
|
||||
header: 'Min Price',
|
||||
align: 'right',
|
||||
width: '100px',
|
||||
render: (tld) => (
|
||||
<span className="font-medium text-foreground">${tld.min_price.toFixed(2)}</span>
|
||||
<span className="font-medium text-foreground tabular-nums">${tld.min_price.toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'avg_price',
|
||||
header: 'Avg Price',
|
||||
align: 'right',
|
||||
width: '100px',
|
||||
hideOnMobile: true,
|
||||
render: (tld) => (
|
||||
<span className="text-foreground-muted">${tld.avg_price.toFixed(2)}</span>
|
||||
<span className="text-foreground-muted tabular-nums">${tld.avg_price.toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'change',
|
||||
header: '7d Change',
|
||||
align: 'right',
|
||||
width: '120px',
|
||||
render: (tld) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{getTrendIcon(tld.price_change_7d)}
|
||||
<span className={clsx(
|
||||
"font-medium",
|
||||
"font-medium tabular-nums",
|
||||
(tld.price_change_7d || 0) > 0 ? "text-orange-400" :
|
||||
(tld.price_change_7d || 0) < 0 ? "text-accent" : "text-foreground-muted"
|
||||
)}>
|
||||
@ -196,19 +241,21 @@ export default function IntelligencePage() {
|
||||
header: 'Cheapest At',
|
||||
hideOnMobile: true,
|
||||
render: (tld) => (
|
||||
<span className="text-foreground-muted truncate max-w-[150px] block">{tld.cheapest_registrar}</span>
|
||||
<span className="text-foreground-muted text-sm truncate max-w-[150px] block">{tld.cheapest_registrar}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right',
|
||||
width: '80px',
|
||||
render: (tld) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<Link
|
||||
href={`/tld-pricing/${tld.tld}`}
|
||||
className="p-2 text-foreground-subtle hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Set price alert"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
</Link>
|
||||
@ -221,22 +268,24 @@ export default function IntelligencePage() {
|
||||
|
||||
{/* Pagination */}
|
||||
{total > 50 && (
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="flex items-center justify-center gap-4 pt-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-foreground-muted">
|
||||
<span className="text-sm text-foreground-muted tabular-nums">
|
||||
Page {page + 1} of {Math.ceil(total / 50)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={(page + 1) * 50 >= total}
|
||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
|
||||
@ -223,10 +223,20 @@ export default function PortfolioPage() {
|
||||
const portfolioLimit = subscription?.portfolio_limit || 0
|
||||
const canAddMore = portfolioLimit === -1 || portfolio.length < portfolioLimit
|
||||
|
||||
// Dynamic subtitle
|
||||
const getSubtitle = () => {
|
||||
if (loading) return 'Loading your portfolio...'
|
||||
if (portfolio.length === 0) return 'Start tracking your domain investments'
|
||||
const profit = summary?.total_profit || 0
|
||||
if (profit > 0) return `${portfolio.length} domains • +$${profit.toLocaleString()} profit`
|
||||
if (profit < 0) return `${portfolio.length} domains • -$${Math.abs(profit).toLocaleString()} loss`
|
||||
return `Managing ${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="Portfolio"
|
||||
subtitle={`Track your domain investments`}
|
||||
subtitle={getSubtitle()}
|
||||
actions={
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { api, DomainHealthReport, HealthStatus } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { PremiumTable, Badge, StatCard, PageContainer, TableActionButton } from '@/components/PremiumTable'
|
||||
import { Toast, useToast } from '@/components/Toast'
|
||||
@ -13,22 +13,64 @@ import {
|
||||
Loader2,
|
||||
Bell,
|
||||
BellOff,
|
||||
History,
|
||||
ExternalLink,
|
||||
Search,
|
||||
Eye,
|
||||
Sparkles,
|
||||
ArrowUpRight,
|
||||
X,
|
||||
Activity,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
ShoppingCart,
|
||||
HelpCircle,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface DomainHistory {
|
||||
id: number
|
||||
status: string
|
||||
is_available: boolean
|
||||
checked_at: string
|
||||
// Health status badge colors and icons
|
||||
const healthStatusConfig: Record<HealthStatus, {
|
||||
label: string
|
||||
color: string
|
||||
bgColor: string
|
||||
icon: typeof Activity
|
||||
description: string
|
||||
}> = {
|
||||
healthy: {
|
||||
label: 'Healthy',
|
||||
color: 'text-accent',
|
||||
bgColor: 'bg-accent/10 border-accent/20',
|
||||
icon: Activity,
|
||||
description: 'Domain is active and well-maintained'
|
||||
},
|
||||
weakening: {
|
||||
label: 'Weakening',
|
||||
color: 'text-amber-400',
|
||||
bgColor: 'bg-amber-400/10 border-amber-400/20',
|
||||
icon: AlertTriangle,
|
||||
description: 'Warning signs detected - owner may be losing interest'
|
||||
},
|
||||
parked: {
|
||||
label: 'For Sale',
|
||||
color: 'text-orange-400',
|
||||
bgColor: 'bg-orange-400/10 border-orange-400/20',
|
||||
icon: ShoppingCart,
|
||||
description: 'Domain is parked and likely for sale'
|
||||
},
|
||||
critical: {
|
||||
label: 'Critical',
|
||||
color: 'text-red-400',
|
||||
bgColor: 'bg-red-400/10 border-red-400/20',
|
||||
icon: AlertTriangle,
|
||||
description: 'Domain drop is imminent!'
|
||||
},
|
||||
unknown: {
|
||||
label: 'Unknown',
|
||||
color: 'text-foreground-muted',
|
||||
bgColor: 'bg-foreground/5 border-border/30',
|
||||
icon: HelpCircle,
|
||||
description: 'Could not determine status'
|
||||
},
|
||||
}
|
||||
|
||||
export default function WatchlistPage() {
|
||||
@ -39,12 +81,14 @@ export default function WatchlistPage() {
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [selectedDomainId, setSelectedDomainId] = useState<number | null>(null)
|
||||
const [domainHistory, setDomainHistory] = useState<DomainHistory[] | null>(null)
|
||||
const [loadingHistory, setLoadingHistory] = useState(false)
|
||||
const [togglingNotifyId, setTogglingNotifyId] = useState<number | null>(null)
|
||||
const [filterStatus, setFilterStatus] = useState<'all' | 'available' | 'watching'>('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)
|
||||
|
||||
// Filter domains
|
||||
const filteredDomains = domains?.filter(domain => {
|
||||
@ -120,10 +164,31 @@ export default function WatchlistPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleHealthCheck = 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 }))
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic subtitle
|
||||
const getSubtitle = () => {
|
||||
if (domainsUsed === 0) return 'Start tracking domains to monitor their availability'
|
||||
return `Monitoring ${domainsUsed} domain${domainsUsed !== 1 ? 's' : ''} • ${domainLimit === -1 ? 'Unlimited' : `${domainLimit - domainsUsed} slots left`}`
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="Watchlist"
|
||||
subtitle={`${domainsUsed}/${domainLimit} domains tracked`}
|
||||
subtitle={getSubtitle()}
|
||||
>
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||
|
||||
@ -131,9 +196,9 @@ export default function WatchlistPage() {
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="Total Watched" value={domainsUsed} icon={Eye} />
|
||||
<StatCard title="Available" value={availableCount} icon={Sparkles} accent />
|
||||
<StatCard title="Watching" value={watchingCount} subtitle="still registered" icon={RefreshCw} />
|
||||
<StatCard title="Limit" value={domainLimit === -1 ? '∞' : domainLimit} subtitle="max domains" icon={Bell} />
|
||||
<StatCard title="Available" value={availableCount} icon={Sparkles} accent={availableCount > 0} />
|
||||
<StatCard title="Monitoring" value={watchingCount} subtitle="active checks" icon={Activity} />
|
||||
<StatCard title="Plan Limit" value={domainLimit === -1 ? '∞' : domainLimit} subtitle={`${domainsUsed} used`} icon={Shield} />
|
||||
</div>
|
||||
|
||||
{/* Add Domain Form */}
|
||||
@ -185,7 +250,7 @@ export default function WatchlistPage() {
|
||||
{[
|
||||
{ id: 'all' as const, label: 'All', count: domainsUsed },
|
||||
{ id: 'available' as const, label: 'Available', count: availableCount, color: 'accent' },
|
||||
{ id: 'watching' as const, label: 'Watching', count: watchingCount },
|
||||
{ id: 'watching' as const, label: 'Monitoring', count: watchingCount },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@ -203,7 +268,7 @@ export default function WatchlistPage() {
|
||||
{tab.id === 'watching' && <span className="w-2 h-2 rounded-full bg-foreground-muted" />}
|
||||
<span>{tab.label}</span>
|
||||
<span className={clsx(
|
||||
"text-xs px-1.5 py-0.5 rounded",
|
||||
"text-xs px-1.5 py-0.5 rounded tabular-nums",
|
||||
filterStatus === tab.id ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{tab.count}</span>
|
||||
</button>
|
||||
@ -263,20 +328,35 @@ export default function WatchlistPage() {
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
align: 'left',
|
||||
hideOnMobile: true,
|
||||
render: (domain) => (
|
||||
<span className={clsx(
|
||||
"text-sm",
|
||||
domain.is_available ? "text-accent font-medium" : "text-foreground-muted"
|
||||
)}>
|
||||
{domain.is_available ? 'Ready to register!' : 'Monitoring...'}
|
||||
</span>
|
||||
),
|
||||
render: (domain) => {
|
||||
const health = healthReports[domain.id]
|
||||
if (health) {
|
||||
const config = healthStatusConfig[health.status]
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<div className={clsx("inline-flex items-center gap-2 px-2.5 py-1 rounded-lg border", config.bgColor)}>
|
||||
<Icon className={clsx("w-3.5 h-3.5", config.color)} />
|
||||
<span className={clsx("text-xs font-medium", config.color)}>{config.label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className={clsx(
|
||||
"text-sm",
|
||||
domain.is_available ? "text-accent font-medium" : "text-foreground-muted"
|
||||
)}>
|
||||
{domain.is_available ? 'Ready to pounce!' : 'Monitoring...'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'notifications',
|
||||
header: 'Alerts',
|
||||
align: 'center',
|
||||
width: '80px',
|
||||
hideOnMobile: true,
|
||||
render: (domain) => (
|
||||
<button
|
||||
@ -309,11 +389,18 @@ export default function WatchlistPage() {
|
||||
align: 'right',
|
||||
render: (domain) => (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<TableActionButton
|
||||
icon={Activity}
|
||||
onClick={() => handleHealthCheck(domain.id)}
|
||||
loading={loadingHealth[domain.id]}
|
||||
title="Health check (DNS, HTTP, SSL)"
|
||||
variant={healthReports[domain.id] ? 'accent' : 'default'}
|
||||
/>
|
||||
<TableActionButton
|
||||
icon={RefreshCw}
|
||||
onClick={() => handleRefresh(domain.id)}
|
||||
loading={refreshingId === domain.id}
|
||||
title="Refresh status"
|
||||
title="Refresh availability"
|
||||
/>
|
||||
<TableActionButton
|
||||
icon={Trash2}
|
||||
@ -339,7 +426,215 @@ export default function WatchlistPage() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Health Report Modal */}
|
||||
{selectedHealthDomainId && healthReports[selectedHealthDomainId] && (
|
||||
<HealthReportModal
|
||||
report={healthReports[selectedHealthDomainId]}
|
||||
onClose={() => setSelectedHealthDomainId(null)}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 border", 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="text-xs text-foreground-muted">{config.description}</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>
|
||||
)
|
||||
}
|
||||
|
||||
16
frontend/src/components/PremiumTable.tsx
Normal file → Executable file
16
frontend/src/components/PremiumTable.tsx
Normal file → Executable file
@ -94,7 +94,7 @@ export function PremiumTable<T>({
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm shadow-[0_4px_24px_-4px_rgba(0,0,0,0.08)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr className="border-b border-border/40 bg-background-secondary/30">
|
||||
{columns.map((col) => (
|
||||
@ -102,19 +102,24 @@ export function PremiumTable<T>({
|
||||
key={col.key}
|
||||
className={clsx(
|
||||
headerPadding,
|
||||
"text-[11px] font-semibold text-foreground-subtle/70 uppercase tracking-wider",
|
||||
"text-[11px] font-semibold text-foreground-subtle/70 uppercase tracking-wider whitespace-nowrap",
|
||||
col.hideOnMobile && "hidden md:table-cell",
|
||||
col.hideOnTablet && "hidden lg:table-cell",
|
||||
col.align === 'right' && "text-right",
|
||||
col.align === 'center' && "text-center",
|
||||
!col.align && "text-left",
|
||||
col.headerClassName
|
||||
)}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
style={col.width ? { width: col.width, minWidth: col.width } : undefined}
|
||||
>
|
||||
{col.sortable && onSort ? (
|
||||
<button
|
||||
onClick={() => onSort(col.key)}
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors group"
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1.5 hover:text-foreground transition-colors group",
|
||||
col.align === 'right' && "justify-end w-full",
|
||||
col.align === 'center' && "justify-center w-full"
|
||||
)}
|
||||
>
|
||||
{col.header}
|
||||
<SortIndicator
|
||||
@ -149,11 +154,12 @@ export function PremiumTable<T>({
|
||||
key={col.key}
|
||||
className={clsx(
|
||||
cellPadding,
|
||||
"text-sm",
|
||||
"text-sm align-middle",
|
||||
col.hideOnMobile && "hidden md:table-cell",
|
||||
col.hideOnTablet && "hidden lg:table-cell",
|
||||
col.align === 'right' && "text-right",
|
||||
col.align === 'center' && "text-center",
|
||||
!col.align && "text-left",
|
||||
col.className
|
||||
)}
|
||||
>
|
||||
|
||||
@ -378,6 +378,16 @@ class ApiClient {
|
||||
}>(`/domains/${domainId}/history?limit=${limit}`)
|
||||
}
|
||||
|
||||
// Domain Health Check - 4-layer analysis (DNS, HTTP, SSL, WHOIS)
|
||||
async getDomainHealth(domainId: number) {
|
||||
return this.request<DomainHealthReport>(`/domains/${domainId}/health`)
|
||||
}
|
||||
|
||||
// Quick health check for any domain (premium)
|
||||
async quickHealthCheck(domain: string) {
|
||||
return this.request<DomainHealthReport>(`/domains/health-check?domain=${encodeURIComponent(domain)}`)
|
||||
}
|
||||
|
||||
// TLD Pricing
|
||||
async getTldOverview(
|
||||
limit = 25,
|
||||
@ -796,6 +806,43 @@ export interface PriceAlert {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Domain Health Check Types
|
||||
export type HealthStatus = 'healthy' | 'weakening' | 'parked' | 'critical' | 'unknown'
|
||||
|
||||
export interface DomainHealthReport {
|
||||
domain: string
|
||||
status: HealthStatus
|
||||
score: number // 0-100
|
||||
checked_at: string
|
||||
signals: string[]
|
||||
recommendations: string[]
|
||||
dns: {
|
||||
has_ns: boolean
|
||||
has_a: boolean
|
||||
has_mx: boolean
|
||||
nameservers: string[]
|
||||
is_parked: boolean
|
||||
parking_provider?: string
|
||||
error?: string
|
||||
}
|
||||
http: {
|
||||
is_reachable: boolean
|
||||
status_code?: number
|
||||
is_parked: boolean
|
||||
parking_keywords?: string[]
|
||||
content_length?: number
|
||||
error?: string
|
||||
}
|
||||
ssl: {
|
||||
has_certificate: boolean
|
||||
is_valid: boolean
|
||||
expires_at?: string
|
||||
days_until_expiry?: number
|
||||
issuer?: string
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Admin API Extension ==============
|
||||
|
||||
class AdminApiClient extends ApiClient {
|
||||
|
||||
Reference in New Issue
Block a user