yves.gugger 9acc40b658
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
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

630 lines
28 KiB
TypeScript
Executable File

'use client'
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { TerminalLayout } from '@/components/TerminalLayout'
import {
ExternalLink,
Loader2,
TrendingUp,
TrendingDown,
Globe,
DollarSign,
AlertTriangle,
RefreshCw,
Search,
ChevronDown,
ChevronUp,
Info,
ArrowRight,
Lock,
Sparkles,
BarChart3,
Activity,
Zap,
Filter,
Check,
Eye,
ShieldCheck,
Diamond,
Minus
} from 'lucide-react'
import clsx from 'clsx'
import Link from 'next/link'
// ============================================================================
// TIER ACCESS LEVELS
// ============================================================================
type UserTier = 'scout' | 'trader' | 'tycoon'
function getTierLevel(tier: UserTier): number {
switch (tier) {
case 'tycoon': return 3
case 'trader': return 2
case 'scout': return 1
default: return 1
}
}
// ============================================================================
// SHARED COMPONENTS
// ============================================================================
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 max-w-xs text-center">
{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 LockedFeature = memo(({ requiredTier, currentTier }: { requiredTier: UserTier; currentTier: UserTier }) => {
const tierNames = { scout: 'Scout', trader: 'Trader', tycoon: 'Tycoon' }
return (
<Tooltip content={`Upgrade to ${tierNames[requiredTier]} to unlock`}>
<div className="flex items-center gap-1.5 text-zinc-600 cursor-help px-2 py-1 rounded bg-zinc-900/50 border border-zinc-800 hover:bg-zinc-900 transition-colors">
<Lock className="w-3 h-3" />
<span className="text-[10px] font-medium uppercase tracking-wider">Locked</span>
</div>
</Tooltip>
)
})
LockedFeature.displayName = 'LockedFeature'
const StatCard = memo(({
label,
value,
subValue,
icon: Icon,
highlight,
locked = false,
lockTooltip
}: {
label: string
value: string | number
subValue?: string
icon: any
highlight?: boolean
locked?: boolean
lockTooltip?: string
}) => (
<div className={clsx(
"bg-zinc-900/40 border p-4 relative overflow-hidden group hover:border-white/10 transition-colors",
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 && "text-emerald-400")} />
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
</div>
{locked ? (
<Tooltip content={lockTooltip || 'Upgrade to unlock'}>
<div className="flex items-center gap-2 text-zinc-600 cursor-help mt-1">
<Lock className="w-5 h-5" />
<span className="text-2xl font-bold"></span>
</div>
</Tooltip>
) : (
<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'
const FilterToggle = memo(({ active, onClick, label, icon: Icon }: {
active: boolean
onClick: () => void
label: string
icon?: any
}) => (
<button
onClick={onClick}
className={clsx(
"px-4 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 whitespace-nowrap border",
active
? "bg-zinc-800 text-white border-zinc-600 shadow-sm"
: "bg-transparent text-zinc-400 border-zinc-800 hover:text-zinc-200 hover:bg-white/5"
)}
>
{Icon && <Icon className="w-3.5 h-3.5" />}
{label}
</button>
))
FilterToggle.displayName = 'FilterToggle'
type SortField = 'tld' | 'price' | 'renewal' | 'change' | 'change3y' | 'risk' | 'popularity'
type SortDirection = 'asc' | 'desc'
const SortableHeader = memo(({
label, field, currentSort, currentDirection, onSort, align = 'left', tooltip, locked = false, lockTooltip
}: {
label: string; field: SortField; currentSort: SortField; currentDirection: SortDirection; onSort: (field: SortField) => void; align?: 'left'|'center'|'right'; tooltip?: string; locked?: boolean; lockTooltip?: string
}) => {
const isActive = currentSort === field
return (
<div className={clsx(
"flex items-center gap-1",
align === 'right' && "justify-end ml-auto",
align === 'center' && "justify-center mx-auto"
)}>
<button
onClick={() => !locked && onSort(field)}
disabled={locked}
className={clsx(
"flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider transition-all group select-none py-2",
locked ? "text-zinc-600 cursor-not-allowed" : isActive ? "text-zinc-300" : "text-zinc-500 hover:text-zinc-400"
)}
>
{label}
{locked ? (
<Tooltip content={lockTooltip || 'Upgrade to unlock'}>
<Lock className="w-2.5 h-2.5 text-zinc-600" />
</Tooltip>
) : (
<div className={clsx("flex flex-col -space-y-1 transition-opacity", isActive ? "opacity-100" : "opacity-0 group-hover:opacity-30")}>
<ChevronUp className={clsx("w-2 h-2", isActive && currentDirection === 'asc' ? "text-zinc-300" : "text-zinc-600")} />
<ChevronDown className={clsx("w-2 h-2", isActive && currentDirection === 'desc' ? "text-zinc-300" : "text-zinc-600")} />
</div>
)}
</button>
{tooltip && !locked && (
<Tooltip content={tooltip}>
<Info className="w-3 h-3 text-zinc-700 hover:text-zinc-500 transition-colors cursor-help" />
</Tooltip>
)}
</div>
)
})
SortableHeader.displayName = 'SortableHeader'
// ============================================================================
// TYPES
// ============================================================================
interface TLDData {
tld: string
min_price: number
avg_price: number
max_price: number
min_renewal_price: number
avg_renewal_price: number
cheapest_registrar?: string
cheapest_registrar_url?: string
price_change_7d: number
price_change_1y: number
price_change_3y: number
risk_level: 'low' | 'medium' | 'high'
risk_reason: string
popularity_rank?: number
type?: string
}
// ============================================================================
// MAIN PAGE
// ============================================================================
export default function IntelPage() {
const { subscription } = useStore()
// Determine user tier
const userTier: UserTier = (subscription?.tier as UserTier) || 'scout'
const tierLevel = getTierLevel(userTier)
// Feature access checks
const canSeeRenewal = tierLevel >= 2 // Trader+
const canSee3yTrend = tierLevel >= 3 // Tycoon only
const canSeeFullHistory = tierLevel >= 3 // Tycoon only
// Data
const [tldData, setTldData] = useState<TLDData[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [total, setTotal] = useState(0)
// Filters
const [searchQuery, setSearchQuery] = useState('')
const [filterType, setFilterType] = useState<'all' | 'tech' | 'geo' | 'budget'>('all')
// Sort
const [sortField, setSortField] = useState<SortField>('popularity')
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
// Load Data
const loadData = useCallback(async () => {
setLoading(true)
try {
const response = await api.getTldOverview(100, 0, 'popularity')
const mapped: TLDData[] = (response.tlds || []).map((tld: any) => ({
tld: tld.tld,
min_price: tld.min_registration_price,
avg_price: tld.avg_registration_price,
max_price: tld.max_registration_price,
min_renewal_price: tld.min_renewal_price,
avg_renewal_price: tld.avg_renewal_price,
price_change_7d: tld.price_change_7d,
price_change_1y: tld.price_change_1y,
price_change_3y: tld.price_change_3y,
risk_level: tld.risk_level,
risk_reason: tld.risk_reason,
popularity_rank: tld.popularity_rank,
type: tld.type,
}))
setTldData(mapped)
setTotal(response.total || 0)
} catch (error) {
console.error('Failed to load TLD data:', error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadData() }, [loadData])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
await loadData()
setRefreshing(false)
}, [loadData])
const handleSort = useCallback((field: SortField) => {
if (field === 'renewal' && !canSeeRenewal) return
if (field === 'change3y' && !canSee3yTrend) return
if (sortField === field) setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
else {
setSortField(field)
setSortDirection(field === 'price' || field === 'renewal' || field === 'risk' ? 'asc' : 'desc')
}
}, [sortField, canSeeRenewal, canSee3yTrend])
// Transform & Filter
const filteredData = useMemo(() => {
let data = tldData
if (filterType === 'tech') data = data.filter(t => ['ai', 'io', 'app', 'dev', 'tech', 'cloud'].includes(t.tld))
if (filterType === 'geo') data = data.filter(t => ['us', 'uk', 'de', 'ch', 'fr', 'eu'].includes(t.tld))
if (filterType === 'budget') data = data.filter(t => t.min_price < 10)
if (searchQuery) {
data = data.filter(t => t.tld.toLowerCase().includes(searchQuery.toLowerCase()))
}
const mult = sortDirection === 'asc' ? 1 : -1
data.sort((a, b) => {
switch (sortField) {
case 'tld': return mult * a.tld.localeCompare(b.tld)
case 'price': return mult * (a.min_price - b.min_price)
case 'renewal': return mult * (a.min_renewal_price - b.min_renewal_price)
case 'change': return mult * ((a.price_change_1y || 0) - (b.price_change_1y || 0))
case 'change3y': return mult * ((a.price_change_3y || 0) - (b.price_change_3y || 0))
case 'risk':
const riskMap = { low: 1, medium: 2, high: 3 }
return mult * (riskMap[a.risk_level] - riskMap[b.risk_level])
case 'popularity': return mult * ((a.popularity_rank || 999) - (b.popularity_rank || 999))
default: return 0
}
})
return data
}, [tldData, filterType, searchQuery, sortField, sortDirection])
const stats = useMemo(() => {
const lowest = tldData.length > 0 ? Math.min(...tldData.map(t => t.min_price)) : 0
const hottest = tldData.reduce((prev, current) => (prev.price_change_1y > current.price_change_1y) ? prev : current, tldData[0] || {})
const traps = tldData.filter(t => t.risk_level === 'high').length
const avgRenewal = tldData.length > 0 ? tldData.reduce((sum, t) => sum + t.min_renewal_price, 0) / tldData.length : 0
return { lowest, hottest, traps, avgRenewal }
}, [tldData])
const formatPrice = (p: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(p)
return (
<TerminalLayout hideHeaderSearch={true}>
<div className="relative font-sans text-zinc-100 selection:bg-emerald-500/30">
{/* 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">TLD Intelligence</h1>
</div>
<p className="text-zinc-400 max-w-lg">
Inflation Monitor & Pricing Analytics across 800+ TLDs.
</p>
</div>
{/* Quick Stats Pills */}
<div className="flex gap-2">
<div className={clsx(
"px-3 py-1.5 rounded-full border flex items-center gap-2 text-xs font-medium",
userTier === 'tycoon' ? "bg-amber-500/5 border-amber-500/20 text-amber-400" :
userTier === 'trader' ? "bg-blue-500/5 border-blue-500/20 text-blue-400" :
"bg-white/5 border-white/10 text-zinc-300"
)}>
<Diamond className="w-3.5 h-3.5" />
{userTier === 'tycoon' ? 'Tycoon Access' : userTier === 'trader' ? 'Trader Access' : 'Scout Access'}
</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-emerald-400" />
{total} Tracked
</div>
</div>
</div>
{/* Metric Grid */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
label="Tracked TLDs"
value={total}
icon={Globe}
highlight={true}
/>
<StatCard
label="Lowest Entry"
value={formatPrice(stats.lowest)}
subValue="Registration"
icon={DollarSign}
/>
<StatCard
label="Avg. Renewal"
value={canSeeRenewal ? formatPrice(stats.avgRenewal) : '—'}
subValue={canSeeRenewal ? "/ year" : undefined}
icon={RefreshCw}
locked={!canSeeRenewal}
lockTooltip="Upgrade to Trader to see renewal prices"
/>
<StatCard
label="Renewal Traps"
value={stats.traps}
subValue="High Risk"
icon={AlertTriangle}
/>
</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 Pills */}
<div className="flex items-center gap-2 overflow-x-auto w-full pb-1 md:pb-0 scrollbar-hide">
<FilterToggle active={filterType === 'all'} onClick={() => setFilterType('all')} label="All TLDs" />
<FilterToggle active={filterType === 'tech'} onClick={() => setFilterType('tech')} label="Tech" icon={Zap} />
<FilterToggle active={filterType === 'geo'} onClick={() => setFilterType('geo')} label="Geo / National" icon={Globe} />
<FilterToggle active={filterType === 'budget'} onClick={() => setFilterType('budget')} label="Budget <$10" icon={DollarSign} />
</div>
{/* Refresh Button (Mobile) */}
<button
onClick={handleRefresh}
className="md:hidden p-2 text-zinc-400 hover:text-white"
>
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
</button>
{/* Search Filter */}
<div className="relative w-full md:w-64 flex-shrink-0">
<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="Search TLDs..."
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>
{/* DATA GRID */}
<div className="bg-zinc-900/40 border border-white/5 rounded-xl overflow-hidden backdrop-blur-sm">
{/* Unified Table Header - Use a wrapper with min-width to force scrolling instead of breaking */}
<div className="overflow-x-auto">
<div className="min-w-[1000px]"> {/* Force minimum width */}
<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 sticky top-0 z-20 backdrop-blur-sm items-center">
<div className="col-span-2">
<SortableHeader label="Extension" field="tld" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} />
</div>
<div className="col-span-2 text-right">
<SortableHeader label="Reg. Price" field="price" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="right" />
</div>
<div className="col-span-2 text-right">
<SortableHeader
label="Renewal"
field="renewal"
currentSort={sortField}
currentDirection={sortDirection}
onSort={handleSort}
align="right"
locked={!canSeeRenewal}
lockTooltip="Upgrade to Trader to unlock"
/>
</div>
<div className="col-span-2 text-center">
<SortableHeader label="Trend (1y)" field="change" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
</div>
<div className="col-span-2 text-center">
{canSee3yTrend ? (
<SortableHeader
label="Trend (3y)"
field="change3y"
currentSort={sortField}
currentDirection={sortDirection}
onSort={handleSort}
align="center"
locked={!canSeeFullHistory}
/>
) : (
<span className="text-zinc-700 select-none">Trend (3y)</span>
)}
</div>
<div className="col-span-1 text-center">
<SortableHeader label="Risk" field="risk" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
</div>
<div className="col-span-1 text-right py-2">Action</div>
</div>
{/* Rows */}
{loading ? (
<div className="flex flex-col items-center justify-center py-32 space-y-4">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
<p className="text-zinc-500 text-sm animate-pulse">Analyzing registry data...</p>
</div>
) : filteredData.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">
<Search className="w-8 h-8 text-zinc-600" />
</div>
<h3 className="text-lg font-medium text-white mb-1">No TLDs found</h3>
<p className="text-zinc-500 text-sm max-w-xs mx-auto">
Try adjusting your filters or search query.
</p>
</div>
) : (
<div className="divide-y divide-white/5">
{filteredData.map((tld) => {
const isTrap = tld.min_renewal_price > tld.min_price * 1.5
const trend = tld.price_change_1y || 0
const trend3y = tld.price_change_3y || 0
return (
<div key={tld.tld} className="grid grid-cols-12 gap-4 px-6 py-4 items-center hover:bg-white/[0.04] transition-all group relative">
{/* TLD */}
<div className="col-span-2">
<Link href={`/terminal/intel/${tld.tld}`} className="flex items-center gap-2 group/link">
<span className="font-mono font-bold text-white text-[15px] group-hover/link:text-emerald-400 transition-colors">.{tld.tld}</span>
</Link>
</div>
{/* Price */}
<div className="col-span-2 text-right">
<span className="font-mono font-medium text-white whitespace-nowrap">{formatPrice(tld.min_price)}</span>
</div>
{/* Renewal (Trader+) */}
<div className="col-span-2 text-right flex items-center justify-end gap-2">
{canSeeRenewal ? (
<>
<span className={clsx("font-mono font-medium whitespace-nowrap", isTrap ? "text-amber-400" : "text-zinc-400")}>
{formatPrice(tld.min_renewal_price)}
</span>
{isTrap && (
<Tooltip content={`Renewal is ${(tld.min_renewal_price/tld.min_price).toFixed(1)}x higher!`}>
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 cursor-help flex-shrink-0" />
</Tooltip>
)}
</>
) : (
<LockedFeature requiredTier="trader" currentTier={userTier} />
)}
</div>
{/* Trend 1y */}
<div className="col-span-2 flex justify-center">
<div className={clsx("flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium font-mono whitespace-nowrap",
trend > 5 ? "text-orange-400 bg-orange-400/5 border border-orange-400/20" :
trend < -5 ? "text-emerald-400 bg-emerald-400/5 border border-emerald-400/20" :
"text-zinc-400 bg-zinc-800/50 border border-zinc-700"
)}>
{trend > 0 ? <TrendingUp className="w-3 h-3" /> : trend < 0 ? <TrendingDown className="w-3 h-3" /> : <Minus className="w-3 h-3" />}
{Math.abs(trend)}%
</div>
</div>
{/* Trend 3y */}
<div className="col-span-2 flex justify-center">
{canSee3yTrend ? (
<div className={clsx("flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium font-mono whitespace-nowrap",
trend3y > 10 ? "text-orange-400 bg-orange-400/5 border border-orange-400/20" :
trend3y < -10 ? "text-emerald-400 bg-emerald-400/5 border border-emerald-400/20" :
"text-zinc-400 bg-zinc-800/50 border border-zinc-700"
)}>
{trend3y > 0 ? <TrendingUp className="w-3 h-3" /> : trend3y < 0 ? <TrendingDown className="w-3 h-3" /> : <Minus className="w-3 h-3" />}
{Math.abs(trend3y)}%
</div>
) : (
<span className="text-zinc-700 text-xs"></span>
)}
</div>
{/* Risk */}
<div className="col-span-1 flex justify-center">
<Tooltip content={tld.risk_reason || 'Standard risk profile'}>
<div className="w-16 h-1.5 rounded-full overflow-hidden bg-zinc-800 cursor-help">
<div className={clsx("h-full rounded-full",
tld.risk_level === 'low' ? "w-1/3 bg-emerald-500" :
tld.risk_level === 'medium' ? "w-2/3 bg-amber-500" :
"w-full bg-red-500"
)} />
</div>
</Tooltip>
</div>
{/* Action */}
<div className="col-span-1 flex justify-end items-center gap-3">
<Link
href={`/terminal/intel/${tld.tld}`}
className="h-8 px-3 flex items-center gap-2 rounded-lg text-xs font-bold transition-all bg-white text-black hover:bg-zinc-200 shadow-white/10 opacity-0 group-hover:opacity-100 uppercase tracking-wide whitespace-nowrap"
>
<ArrowRight className="w-3 h-3" />
</Link>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
{/* Upgrade CTA for Scout users */}
{userTier === 'scout' && (
<div className="mt-8 p-6 rounded-2xl bg-gradient-to-br from-zinc-900 to-zinc-900/50 border border-white/5 text-center">
<div className="w-12 h-12 bg-emerald-500/10 rounded-full flex items-center justify-center mx-auto mb-4 text-emerald-400">
<BarChart3 className="w-6 h-6" />
</div>
<h3 className="text-lg font-bold text-white mb-2">Unlock Full TLD Intelligence</h3>
<p className="text-sm text-zinc-400 mb-4 max-w-md mx-auto">
See renewal prices, identify renewal traps, and access detailed price history charts with Trader or Tycoon.
</p>
<Link
href="/pricing"
className="inline-flex items-center gap-2 px-6 py-3 bg-white text-black font-bold rounded-lg hover:bg-zinc-200 transition-all"
>
<Sparkles className="w-4 h-4" />
Upgrade Now
</Link>
</div>
)}
</div>
</div>
</TerminalLayout>
)
}