- Renamed /command/* routes to /terminal/* - Renamed CommandCenterLayout to TerminalLayout - Updated all internal links - Added permanent redirects from /command/* to /terminal/* - Updated Sidebar navigation - Added concept docs (pounce_*.md)
621 lines
23 KiB
TypeScript
Executable File
621 lines
23 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 {
|
|
PremiumTable,
|
|
Badge,
|
|
StatCard,
|
|
PageContainer,
|
|
TableActionButton,
|
|
SearchInput,
|
|
TabBar,
|
|
FilterBar,
|
|
ActionButton,
|
|
} from '@/components/PremiumTable'
|
|
import { Toast, useToast } from '@/components/Toast'
|
|
import {
|
|
Plus,
|
|
Trash2,
|
|
RefreshCw,
|
|
Loader2,
|
|
Bell,
|
|
BellOff,
|
|
ExternalLink,
|
|
Eye,
|
|
Sparkles,
|
|
ArrowUpRight,
|
|
X,
|
|
Activity,
|
|
Shield,
|
|
AlertTriangle,
|
|
ShoppingCart,
|
|
HelpCircle,
|
|
} from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
import Link from 'next/link'
|
|
|
|
// 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'
|
|
},
|
|
}
|
|
|
|
type FilterStatus = 'all' | 'available' | 'watching'
|
|
|
|
export default function WatchlistPage() {
|
|
const { domains, addDomain, deleteDomain, refreshDomain, 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 [filterStatus, setFilterStatus] = useState<FilterStatus>('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 - avoids recalculation on every render
|
|
const stats = useMemo(() => ({
|
|
availableCount: domains?.filter(d => d.is_available).length || 0,
|
|
watchingCount: domains?.filter(d => !d.is_available).length || 0,
|
|
domainsUsed: domains?.length || 0,
|
|
domainLimit: subscription?.domain_limit || 5,
|
|
}), [domains, subscription?.domain_limit])
|
|
|
|
const canAddMore = stats.domainsUsed < stats.domainLimit
|
|
|
|
// Memoized filtered domains
|
|
const filteredDomains = useMemo(() => {
|
|
if (!domains) return []
|
|
|
|
return domains.filter(domain => {
|
|
if (searchQuery && !domain.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
|
return false
|
|
}
|
|
if (filterStatus === 'available' && !domain.is_available) return false
|
|
if (filterStatus === 'watching' && domain.is_available) return false
|
|
return true
|
|
})
|
|
}, [domains, searchQuery, filterStatus])
|
|
|
|
// Memoized tabs config
|
|
const tabs = useMemo(() => [
|
|
{ id: 'all', label: 'All', count: stats.domainsUsed },
|
|
{ id: 'available', label: 'Available', count: stats.availableCount, color: 'accent' as const },
|
|
{ id: 'watching', label: 'Monitoring', count: stats.watchingCount },
|
|
], [stats])
|
|
|
|
// Callbacks - prevent recreation on every render
|
|
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)
|
|
showToast(!currentState ? 'Notifications enabled' : 'Notifications disabled', 'success')
|
|
} catch (err: any) {
|
|
showToast(err.message || 'Failed to update', 'error')
|
|
} finally {
|
|
setTogglingNotifyId(null)
|
|
}
|
|
}, [showToast])
|
|
|
|
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])
|
|
|
|
// Dynamic subtitle
|
|
const subtitle = useMemo(() => {
|
|
if (stats.domainsUsed === 0) return 'Start tracking domains to monitor their availability'
|
|
return `Monitoring ${stats.domainsUsed} domain${stats.domainsUsed !== 1 ? 's' : ''} • ${stats.domainLimit === -1 ? 'Unlimited' : `${stats.domainLimit - stats.domainsUsed} slots left`}`
|
|
}, [stats])
|
|
|
|
// Memoized columns config
|
|
const columns = useMemo(() => [
|
|
{
|
|
key: 'domain',
|
|
header: 'Domain',
|
|
render: (domain: any) => (
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative">
|
|
<span className={clsx(
|
|
"block w-3 h-3 rounded-full",
|
|
domain.is_available ? "bg-accent" : "bg-foreground-muted/50"
|
|
)} />
|
|
{domain.is_available && (
|
|
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-50" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<span className="font-mono font-medium text-foreground">{domain.name}</span>
|
|
{domain.is_available && (
|
|
<span className="ml-2"><Badge variant="success" size="xs">AVAILABLE</Badge></span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'Status',
|
|
align: 'left' as const,
|
|
hideOnMobile: true,
|
|
render: (domain: any) => {
|
|
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' as const,
|
|
width: '80px',
|
|
hideOnMobile: true,
|
|
render: (domain: any) => (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
handleToggleNotify(domain.id, domain.notify_on_available)
|
|
}}
|
|
disabled={togglingNotifyId === domain.id}
|
|
className={clsx(
|
|
"p-2 rounded-lg transition-colors",
|
|
domain.notify_on_available
|
|
? "bg-accent/10 text-accent hover:bg-accent/20"
|
|
: "text-foreground-muted hover:bg-foreground/5"
|
|
)}
|
|
title={domain.notify_on_available ? "Disable alerts" : "Enable alerts"}
|
|
>
|
|
{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>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
align: 'right' as const,
|
|
render: (domain: any) => (
|
|
<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 availability"
|
|
/>
|
|
<TableActionButton
|
|
icon={Trash2}
|
|
onClick={() => handleDelete(domain.id, domain.name)}
|
|
variant="danger"
|
|
loading={deletingId === domain.id}
|
|
title="Remove"
|
|
/>
|
|
{domain.is_available && (
|
|
<a
|
|
href={`https://www.namecheap.com/domains/registration/results/?domain=${domain.name}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="flex items-center gap-1.5 px-3 py-2 bg-accent text-background text-xs font-medium
|
|
rounded-lg hover:bg-accent-hover transition-colors ml-1"
|
|
>
|
|
Register <ExternalLink className="w-3 h-3" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
], [healthReports, togglingNotifyId, loadingHealth, refreshingId, deletingId, handleToggleNotify, handleHealthCheck, handleRefresh, handleDelete])
|
|
|
|
return (
|
|
<TerminalLayout title="Watchlist" subtitle={subtitle}>
|
|
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
|
|
|
<PageContainer>
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<StatCard title="Total Watched" value={stats.domainsUsed} icon={Eye} />
|
|
<StatCard title="Available" value={stats.availableCount} icon={Sparkles} />
|
|
<StatCard title="Monitoring" value={stats.watchingCount} subtitle="active checks" icon={Activity} />
|
|
<StatCard title="Plan Limit" value={stats.domainLimit === -1 ? '∞' : stats.domainLimit} subtitle={`${stats.domainsUsed} used`} icon={Shield} />
|
|
</div>
|
|
|
|
{/* Add Domain Form */}
|
|
<FilterBar>
|
|
<SearchInput
|
|
value={newDomain}
|
|
onChange={setNewDomain}
|
|
placeholder="Enter domain to track (e.g., dream.com)"
|
|
className="flex-1"
|
|
/>
|
|
<ActionButton
|
|
onClick={() => handleAddDomain({} as React.FormEvent)}
|
|
disabled={adding || !newDomain.trim() || !canAddMore}
|
|
icon={adding ? Loader2 : Plus}
|
|
>
|
|
Add Domain
|
|
</ActionButton>
|
|
</FilterBar>
|
|
|
|
{!canAddMore && (
|
|
<div className="flex items-center justify-between p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl">
|
|
<p className="text-sm text-amber-400">
|
|
You've reached your domain limit. Upgrade to track more.
|
|
</p>
|
|
<Link
|
|
href="/pricing"
|
|
className="text-sm font-medium text-amber-400 hover:text-amber-300 flex items-center gap-1"
|
|
>
|
|
Upgrade <ArrowUpRight className="w-3 h-3" />
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
<FilterBar className="justify-between">
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTab={filterStatus}
|
|
onChange={(id) => setFilterStatus(id as FilterStatus)}
|
|
/>
|
|
<SearchInput
|
|
value={searchQuery}
|
|
onChange={setSearchQuery}
|
|
placeholder="Filter domains..."
|
|
className="w-full sm:w-64"
|
|
/>
|
|
</FilterBar>
|
|
|
|
{/* Domain Table */}
|
|
<PremiumTable
|
|
data={filteredDomains}
|
|
keyExtractor={(d) => d.id}
|
|
emptyIcon={<Eye className="w-12 h-12 text-foreground-subtle" />}
|
|
emptyTitle={stats.domainsUsed === 0 ? "Your watchlist is empty" : "No domains match your filters"}
|
|
emptyDescription={stats.domainsUsed === 0 ? "Add a domain above to start tracking" : "Try adjusting your filter criteria"}
|
|
columns={columns}
|
|
/>
|
|
|
|
{/* Health Report Modal */}
|
|
{selectedHealthDomainId && healthReports[selectedHealthDomainId] && (
|
|
<HealthReportModal
|
|
report={healthReports[selectedHealthDomainId]}
|
|
onClose={() => setSelectedHealthDomainId(null)}
|
|
/>
|
|
)}
|
|
</PageContainer>
|
|
</TerminalLayout>
|
|
)
|
|
}
|
|
|
|
// Health Report Modal Component - memoized
|
|
const HealthReportModal = memo(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 max-h-80 overflow-y-auto">
|
|
{/* 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>
|
|
)
|
|
})
|