feat: Complete TLD Pricing feature across Public → Command → Admin

PUBLIC PAGE (/tld-pricing):
- Added renewal price column with trap warning (⚠️ when >2x)
- Added 1y trend % column with color coding
- Added risk level badges (🟢🟡🔴)
- Blur effect for non-authenticated users on premium columns
- All data from real backend API (not simulated)

PUBLIC DETAIL PAGE (/tld-pricing/[tld]):
- Already existed with full features
- Shows price history chart, registrar comparison, domain check

COMMAND CENTER (/command/pricing):
- Full access to all data without blur
- Category tabs: All, Tech, Geo, Budget, Premium
- Sparklines for trend visualization
- Risk-based sorting option

COMMAND CENTER DETAIL (/command/pricing/[tld]):
- NEW: Professional detail page with CommandCenterLayout
- Price history chart with period selection (1M, 3M, 1Y, ALL)
- Renewal trap warning banner
- Registrar comparison table with trap indicators
- Quick domain availability check
- TLD info grid (type, registry, introduced, registrars)
- Price alert toggle

CLEANUP:
- /intelligence → redirects to /tld-pricing (backwards compat)
- Removed duplicate code

All TLD Pricing data now flows from backend with:
- Real renewal prices from registrar data
- Calculated 1y/3y trends per TLD
- Risk level and reason from backend
This commit is contained in:
yves.gugger
2025-12-10 13:50:21 +01:00
parent feded3eec2
commit 9f918002ea
3 changed files with 757 additions and 541 deletions

View File

@ -0,0 +1,678 @@
'use client'
import { useEffect, useState, useMemo, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import { PageContainer, StatCard } from '@/components/PremiumTable'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import {
ArrowLeft,
TrendingUp,
TrendingDown,
Minus,
Calendar,
Globe,
Building,
ExternalLink,
Bell,
Search,
ChevronRight,
Sparkles,
Check,
X,
RefreshCw,
Clock,
Shield,
Zap,
AlertTriangle,
DollarSign,
BarChart3,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
interface TldDetails {
tld: string
type: string
description: string
registry: string
introduced: number
trend: string
trend_reason: string
pricing: {
avg: number
min: number
max: number
}
registrars: Array<{
name: string
registration_price: number
renewal_price: number
transfer_price: number
}>
cheapest_registrar: string
}
interface TldHistory {
tld: string
current_price: number
price_change_7d: number
price_change_30d: number
price_change_90d: number
trend: string
trend_reason: string
history: Array<{
date: string
price: number
}>
}
interface DomainCheckResult {
domain: string
is_available: boolean
status: string
registrar?: string | null
creation_date?: string | null
expiration_date?: string | null
}
// Registrar URLs
const REGISTRAR_URLS: Record<string, string> = {
'Namecheap': 'https://www.namecheap.com/domains/registration/results/?domain=',
'Porkbun': 'https://porkbun.com/checkout/search?q=',
'Cloudflare': 'https://www.cloudflare.com/products/registrar/',
'Google Domains': 'https://domains.google.com/registrar/search?searchTerm=',
'GoDaddy': 'https://www.godaddy.com/domainsearch/find?domainToCheck=',
'porkbun': 'https://porkbun.com/checkout/search?q=',
'Dynadot': 'https://www.dynadot.com/domain/search?domain=',
}
type ChartPeriod = '1M' | '3M' | '1Y' | 'ALL'
// Premium Chart Component
function PriceChart({
data,
chartStats,
}: {
data: Array<{ date: string; price: number }>
chartStats: { high: number; low: number; avg: number }
}) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
if (data.length === 0) {
return (
<div className="h-48 flex items-center justify-center text-foreground-muted">
No price history available
</div>
)
}
const minPrice = Math.min(...data.map(d => d.price))
const maxPrice = Math.max(...data.map(d => d.price))
const priceRange = maxPrice - minPrice || 1
const points = data.map((d, i) => ({
x: (i / (data.length - 1)) * 100,
y: 100 - ((d.price - minPrice) / priceRange) * 80 - 10,
...d,
}))
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
const areaPath = linePath + ` L${points[points.length - 1].x},100 L${points[0].x},100 Z`
const isRising = data[data.length - 1].price > data[0].price
const strokeColor = isRising ? '#f97316' : '#00d4aa'
return (
<div
ref={containerRef}
className="relative h-48"
onMouseLeave={() => setHoveredIndex(null)}
>
<svg
className="w-full h-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
onMouseMove={(e) => {
if (!containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const x = ((e.clientX - rect.left) / rect.width) * 100
const idx = Math.round((x / 100) * (points.length - 1))
setHoveredIndex(Math.max(0, Math.min(idx, points.length - 1)))
}}
>
<defs>
<linearGradient id="chartGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.02" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#chartGradient)" />
<path d={linePath} fill="none" stroke={strokeColor} strokeWidth="2" />
{hoveredIndex !== null && points[hoveredIndex] && (
<circle
cx={points[hoveredIndex].x}
cy={points[hoveredIndex].y}
r="4"
fill={strokeColor}
stroke="#0a0a0a"
strokeWidth="2"
/>
)}
</svg>
{/* Tooltip */}
{hoveredIndex !== null && points[hoveredIndex] && (
<div
className="absolute -top-2 transform -translate-x-1/2 bg-background border border-border rounded-lg px-3 py-2 shadow-lg z-10 pointer-events-none"
style={{ left: `${points[hoveredIndex].x}%` }}
>
<p className="text-sm font-medium text-foreground">${points[hoveredIndex].price.toFixed(2)}</p>
<p className="text-xs text-foreground-muted">{new Date(points[hoveredIndex].date).toLocaleDateString()}</p>
</div>
)}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 flex flex-col justify-between text-xs text-foreground-subtle -ml-12 w-10 text-right">
<span>${maxPrice.toFixed(2)}</span>
<span>${((maxPrice + minPrice) / 2).toFixed(2)}</span>
<span>${minPrice.toFixed(2)}</span>
</div>
</div>
)
}
export default function CommandTldDetailPage() {
const params = useParams()
const router = useRouter()
const { subscription, fetchSubscription } = useStore()
const tld = params.tld as string
const [details, setDetails] = useState<TldDetails | null>(null)
const [history, setHistory] = useState<TldHistory | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [chartPeriod, setChartPeriod] = useState<ChartPeriod>('1Y')
const [domainSearch, setDomainSearch] = useState('')
const [checkingDomain, setCheckingDomain] = useState(false)
const [domainResult, setDomainResult] = useState<DomainCheckResult | null>(null)
const [alertEnabled, setAlertEnabled] = useState(false)
const [alertLoading, setAlertLoading] = useState(false)
useEffect(() => {
fetchSubscription()
if (tld) {
loadData()
loadAlertStatus()
}
}, [tld, fetchSubscription])
const loadAlertStatus = async () => {
try {
const status = await api.getPriceAlertStatus(tld)
setAlertEnabled(status.has_alert && status.is_active)
} catch (err) {
// Ignore
}
}
const handleToggleAlert = async () => {
setAlertLoading(true)
try {
const result = await api.togglePriceAlert(tld)
setAlertEnabled(result.is_active)
} catch (err) {
console.error('Failed to toggle alert:', err)
} finally {
setAlertLoading(false)
}
}
const loadData = async () => {
try {
const [historyData, compareData] = await Promise.all([
api.getTldHistory(tld, 365),
api.getTldCompare(tld),
])
if (historyData && compareData) {
const sortedRegistrars = [...(compareData.registrars || [])].sort((a, b) =>
a.registration_price - b.registration_price
)
setDetails({
tld: compareData.tld || tld,
type: compareData.type || 'generic',
description: compareData.description || `Domain extension .${tld}`,
registry: compareData.registry || 'Various',
introduced: compareData.introduced || 0,
trend: historyData.trend || 'stable',
trend_reason: historyData.trend_reason || 'Price tracking available',
pricing: {
avg: compareData.price_range?.avg || historyData.current_price || 0,
min: compareData.price_range?.min || historyData.current_price || 0,
max: compareData.price_range?.max || historyData.current_price || 0,
},
registrars: sortedRegistrars,
cheapest_registrar: compareData.cheapest_registrar || sortedRegistrars[0]?.name || 'N/A',
})
setHistory(historyData)
} else {
setError('Failed to load TLD data')
}
} catch (err) {
console.error('Error loading TLD data:', err)
setError('Failed to load TLD data')
} finally {
setLoading(false)
}
}
const filteredHistory = useMemo(() => {
if (!history?.history) return []
const now = new Date()
let cutoffDays = 365
switch (chartPeriod) {
case '1M': cutoffDays = 30; break
case '3M': cutoffDays = 90; break
case '1Y': cutoffDays = 365; break
case 'ALL': cutoffDays = 9999; break
}
const cutoff = new Date(now.getTime() - cutoffDays * 24 * 60 * 60 * 1000)
return history.history.filter(h => new Date(h.date) >= cutoff)
}, [history, chartPeriod])
const chartStats = useMemo(() => {
if (filteredHistory.length === 0) return { high: 0, low: 0, avg: 0 }
const prices = filteredHistory.map(h => h.price)
return {
high: Math.max(...prices),
low: Math.min(...prices),
avg: prices.reduce((a, b) => a + b, 0) / prices.length,
}
}, [filteredHistory])
const handleDomainCheck = async () => {
if (!domainSearch.trim()) return
setCheckingDomain(true)
setDomainResult(null)
try {
const domain = domainSearch.includes('.') ? domainSearch : `${domainSearch}.${tld}`
const result = await api.checkDomain(domain, false)
setDomainResult({
domain,
is_available: result.is_available,
status: result.status,
registrar: result.registrar,
creation_date: result.creation_date,
expiration_date: result.expiration_date,
})
} catch (err) {
console.error('Domain check failed:', err)
} finally {
setCheckingDomain(false)
}
}
const getRegistrarUrl = (registrarName: string, domain?: string) => {
const baseUrl = REGISTRAR_URLS[registrarName]
if (!baseUrl) return '#'
if (domain) return `${baseUrl}${domain}`
return baseUrl
}
// Calculate renewal trap info
const getRenewalInfo = () => {
if (!details?.registrars?.length) return null
const cheapest = details.registrars[0]
const ratio = cheapest.renewal_price / cheapest.registration_price
return {
registration: cheapest.registration_price,
renewal: cheapest.renewal_price,
ratio,
isTrap: ratio > 2,
}
}
const renewalInfo = getRenewalInfo()
const getTrendIcon = (trend: string) => {
switch (trend) {
case 'up': return <TrendingUp className="w-4 h-4" />
case 'down': return <TrendingDown className="w-4 h-4" />
default: return <Minus className="w-4 h-4" />
}
}
if (loading) {
return (
<CommandCenterLayout title={`.${tld}`} subtitle="Loading...">
<PageContainer>
<div className="flex items-center justify-center py-20">
<RefreshCw className="w-6 h-6 text-accent animate-spin" />
</div>
</PageContainer>
</CommandCenterLayout>
)
}
if (error || !details) {
return (
<CommandCenterLayout title="TLD Not Found" subtitle="Error loading data">
<PageContainer>
<div className="text-center py-20">
<div className="w-16 h-16 bg-background-secondary rounded-full flex items-center justify-center mx-auto mb-6">
<X className="w-8 h-8 text-foreground-subtle" />
</div>
<h1 className="text-xl font-medium text-foreground mb-2">TLD Not Found</h1>
<p className="text-foreground-muted mb-8">{error || `The TLD .${tld} could not be found.`}</p>
<Link
href="/command/pricing"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-all"
>
<ArrowLeft className="w-4 h-4" />
Back to TLD Pricing
</Link>
</div>
</PageContainer>
</CommandCenterLayout>
)
}
return (
<CommandCenterLayout
title={`.${details.tld}`}
subtitle={details.description}
actions={
<button
onClick={handleToggleAlert}
disabled={alertLoading}
className={clsx(
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all",
alertEnabled
? "bg-accent/10 text-accent border border-accent/30"
: "bg-foreground/5 text-foreground-muted hover:text-foreground hover:bg-foreground/10"
)}
>
<Bell className={clsx("w-4 h-4", alertEnabled && "fill-current")} />
{alertEnabled ? 'Alert On' : 'Set Alert'}
</button>
}
>
<PageContainer>
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm mb-6">
<Link href="/command/pricing" className="text-foreground-subtle hover:text-foreground transition-colors">
TLD Pricing
</Link>
<ChevronRight className="w-3.5 h-3.5 text-foreground-subtle" />
<span className="text-foreground font-medium">.{details.tld}</span>
</nav>
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Best Price"
value={`$${details.pricing.min.toFixed(2)}`}
subtitle={`at ${details.cheapest_registrar}`}
icon={DollarSign}
accent
/>
<StatCard
title="Renewal"
value={renewalInfo ? `$${renewalInfo.renewal.toFixed(2)}` : '—'}
subtitle={renewalInfo?.isTrap ? `⚠️ ${renewalInfo.ratio.toFixed(1)}x registration` : 'per year'}
icon={RefreshCw}
/>
<StatCard
title="7d Change"
value={history ? `${history.price_change_7d > 0 ? '+' : ''}${history.price_change_7d.toFixed(1)}%` : '—'}
icon={BarChart3}
/>
<StatCard
title="Trend"
value={details.trend === 'up' ? 'Rising' : details.trend === 'down' ? 'Falling' : 'Stable'}
subtitle={details.trend_reason}
icon={details.trend === 'up' ? TrendingUp : details.trend === 'down' ? TrendingDown : Minus}
/>
</div>
{/* Renewal Trap Warning */}
{renewalInfo?.isTrap && (
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-400">Renewal Trap Detected</p>
<p className="text-sm text-foreground-muted mt-1">
The renewal price (${renewalInfo.renewal.toFixed(2)}) is {renewalInfo.ratio.toFixed(1)}x higher than the registration price (${renewalInfo.registration.toFixed(2)}).
Consider the total cost of ownership before registering.
</p>
</div>
</div>
)}
{/* Price Chart */}
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-medium text-foreground">Price History</h2>
<div className="flex items-center gap-1 bg-foreground/5 rounded-lg p-1">
{(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map((period) => (
<button
key={period}
onClick={() => setChartPeriod(period)}
className={clsx(
"px-3 py-1.5 text-xs font-medium rounded-md transition-all",
chartPeriod === period
? "bg-accent text-background"
: "text-foreground-muted hover:text-foreground"
)}
>
{period}
</button>
))}
</div>
</div>
<div className="pl-14">
<PriceChart data={filteredHistory} chartStats={chartStats} />
</div>
{/* Chart Stats */}
<div className="grid grid-cols-3 gap-4 mt-6 pt-6 border-t border-border/30">
<div className="text-center">
<p className="text-xs text-foreground-subtle uppercase mb-1">Period High</p>
<p className="text-lg font-medium text-foreground tabular-nums">${chartStats.high.toFixed(2)}</p>
</div>
<div className="text-center">
<p className="text-xs text-foreground-subtle uppercase mb-1">Average</p>
<p className="text-lg font-medium text-foreground tabular-nums">${chartStats.avg.toFixed(2)}</p>
</div>
<div className="text-center">
<p className="text-xs text-foreground-subtle uppercase mb-1">Period Low</p>
<p className="text-lg font-medium text-foreground tabular-nums">${chartStats.low.toFixed(2)}</p>
</div>
</div>
</div>
{/* Registrar Comparison */}
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
<h2 className="text-lg font-medium text-foreground mb-6">Registrar Comparison</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/30">
<th className="text-left pb-3 text-sm font-medium text-foreground-muted">Registrar</th>
<th className="text-right pb-3 text-sm font-medium text-foreground-muted">Register</th>
<th className="text-right pb-3 text-sm font-medium text-foreground-muted">Renew</th>
<th className="text-right pb-3 text-sm font-medium text-foreground-muted">Transfer</th>
<th className="text-right pb-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-border/20">
{details.registrars.map((registrar, idx) => (
<tr key={registrar.name} className={clsx(idx === 0 && "bg-accent/5")}>
<td className="py-4">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{registrar.name}</span>
{idx === 0 && (
<span className="px-2 py-0.5 text-xs bg-accent/10 text-accent rounded-full">Cheapest</span>
)}
</div>
</td>
<td className="py-4 text-right">
<span className={clsx(
"font-medium tabular-nums",
idx === 0 ? "text-accent" : "text-foreground"
)}>
${registrar.registration_price.toFixed(2)}
</span>
</td>
<td className="py-4 text-right">
<div className="flex items-center gap-1 justify-end">
<span className="text-foreground-muted tabular-nums">${registrar.renewal_price.toFixed(2)}</span>
{registrar.renewal_price / registrar.registration_price > 2 && (
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
)}
</div>
</td>
<td className="py-4 text-right">
<span className="text-foreground-muted tabular-nums">${registrar.transfer_price.toFixed(2)}</span>
</td>
<td className="py-4 text-right">
<a
href={getRegistrarUrl(registrar.name)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-accent hover:text-accent/80 transition-colors"
>
Visit
<ExternalLink className="w-3.5 h-3.5" />
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Quick Domain Check */}
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
<h2 className="text-lg font-medium text-foreground mb-4">Quick Domain Check</h2>
<p className="text-sm text-foreground-muted mb-4">
Check if a domain is available with .{tld}
</p>
<div className="flex gap-3">
<div className="flex-1 relative">
<input
type="text"
value={domainSearch}
onChange={(e) => setDomainSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleDomainCheck()}
placeholder={`example or example.${tld}`}
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl
text-sm text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:border-accent/50 transition-all"
/>
</div>
<button
onClick={handleDomainCheck}
disabled={checkingDomain || !domainSearch.trim()}
className="h-11 px-6 bg-accent text-background font-medium rounded-xl
hover:bg-accent-hover transition-all disabled:opacity-50"
>
{checkingDomain ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
'Check'
)}
</button>
</div>
{/* Result */}
{domainResult && (
<div className={clsx(
"mt-4 p-4 rounded-xl border",
domainResult.is_available
? "bg-accent/10 border-accent/30"
: "bg-foreground/5 border-border/50"
)}>
<div className="flex items-center gap-3">
{domainResult.is_available ? (
<Check className="w-5 h-5 text-accent" />
) : (
<X className="w-5 h-5 text-foreground-subtle" />
)}
<div>
<p className="font-medium text-foreground">{domainResult.domain}</p>
<p className="text-sm text-foreground-muted">
{domainResult.is_available ? 'Available for registration!' : 'Already registered'}
</p>
</div>
</div>
{domainResult.is_available && (
<a
href={getRegistrarUrl(details.cheapest_registrar, domainResult.domain)}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg hover:bg-accent-hover transition-all"
>
Register at {details.cheapest_registrar}
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
)}
</div>
{/* TLD Info */}
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
<h2 className="text-lg font-medium text-foreground mb-4">TLD Information</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-4 bg-background/50 rounded-xl">
<div className="flex items-center gap-2 text-foreground-muted mb-2">
<Globe className="w-4 h-4" />
<span className="text-xs uppercase">Type</span>
</div>
<p className="font-medium text-foreground capitalize">{details.type}</p>
</div>
<div className="p-4 bg-background/50 rounded-xl">
<div className="flex items-center gap-2 text-foreground-muted mb-2">
<Building className="w-4 h-4" />
<span className="text-xs uppercase">Registry</span>
</div>
<p className="font-medium text-foreground">{details.registry}</p>
</div>
<div className="p-4 bg-background/50 rounded-xl">
<div className="flex items-center gap-2 text-foreground-muted mb-2">
<Calendar className="w-4 h-4" />
<span className="text-xs uppercase">Introduced</span>
</div>
<p className="font-medium text-foreground">{details.introduced || 'Unknown'}</p>
</div>
<div className="p-4 bg-background/50 rounded-xl">
<div className="flex items-center gap-2 text-foreground-muted mb-2">
<BarChart3 className="w-4 h-4" />
<span className="text-xs uppercase">Registrars</span>
</div>
<p className="font-medium text-foreground">{details.registrars.length} tracked</p>
</div>
</div>
</div>
</PageContainer>
</CommandCenterLayout>
)
}

547
frontend/src/app/intelligence/page.tsx Executable file → Normal file
View File

@ -1,543 +1,26 @@
'use client'
import { useEffect, useState } from 'react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { PremiumTable, Badge, StatCard } from '@/components/PremiumTable'
import {
Search,
TrendingUp,
TrendingDown,
Minus,
ChevronRight,
Globe,
ArrowUpDown,
DollarSign,
BarChart3,
RefreshCw,
X,
ArrowRight,
Lock,
Sparkles,
AlertTriangle,
Cpu,
MapPin,
Coins,
Crown,
Info,
} from 'lucide-react'
import clsx from 'clsx'
import Link from 'next/link'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
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
}
// Category definitions
const CATEGORIES = {
all: { label: 'All', icon: Globe, filter: () => true },
tech: { label: 'Tech', icon: Cpu, filter: (tld: TLDData) => ['ai', 'io', 'app', 'dev', 'tech', 'code', 'cloud', 'data', 'api', 'software'].includes(tld.tld) },
geo: { label: 'Geo', icon: MapPin, filter: (tld: TLDData) => ['ch', 'de', 'uk', 'us', 'fr', 'it', 'es', 'nl', 'at', 'eu', 'co', 'ca', 'au', 'nz', 'jp', 'cn', 'in', 'br', 'mx', 'nyc', 'london', 'paris', 'berlin', 'tokyo', 'swiss'].includes(tld.tld) },
budget: { label: 'Budget', icon: Coins, filter: (tld: TLDData) => tld.min_price < 5 },
premium: { label: 'Premium', icon: Crown, filter: (tld: TLDData) => tld.min_price >= 50 },
}
type CategoryKey = keyof typeof CATEGORIES
// Risk level now comes from backend
function getRiskInfo(tld: TLDData): { level: 'low' | 'medium' | 'high', reason: string } {
return {
level: tld.risk_level || 'low',
reason: tld.risk_reason || 'Stable'
}
}
// Sparkline component
function Sparkline({ trend, className }: { trend: number, className?: string }) {
const isPositive = trend > 0
const isNeutral = trend === 0
return (
<div className={clsx("flex items-center gap-1", className)}>
<svg width="40" height="16" viewBox="0 0 40 16" className="overflow-visible">
{isNeutral ? (
<line x1="0" y1="8" x2="40" y2="8" stroke="currentColor" className="text-foreground-muted" strokeWidth="1.5" />
) : isPositive ? (
<polyline
points="0,14 10,12 20,10 30,6 40,2"
fill="none"
stroke="currentColor"
className="text-orange-400"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
) : (
<polyline
points="0,2 10,4 20,8 30,12 40,14"
fill="none"
stroke="currentColor"
className="text-accent"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
</div>
)
}
export default function TLDPricingPublicPage() {
const { isAuthenticated, checkAuth } = useStore()
const [tldData, setTldData] = useState<TLDData[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<'popularity' | 'price_asc' | 'price_desc' | 'change'>('popularity')
const [category, setCategory] = useState<CategoryKey>('all')
const [page, setPage] = useState(0)
const [total, setTotal] = useState(0)
// Number of visible rows for non-authenticated users before blur
const FREE_VISIBLE_ROWS = 5
/**
* Redirect /intelligence to /tld-pricing
* This page is kept for backwards compatibility
*/
export default function IntelligenceRedirect() {
const router = useRouter()
useEffect(() => {
checkAuth()
}, [checkAuth])
useEffect(() => {
loadTLDData()
}, [page, sortBy])
const loadTLDData = async () => {
setLoading(true)
try {
const response = await api.getTldOverview(
50,
page * 50,
sortBy,
)
// Map API response to component interface
const mapped: TLDData[] = (response.tlds || []).map((tld) => ({
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)
}
}
const handleRefresh = async () => {
setRefreshing(true)
await loadTLDData()
setRefreshing(false)
}
// Apply filters
const filteredData = tldData
.filter(tld => CATEGORIES[category].filter(tld))
.filter(tld => tld.tld.toLowerCase().includes(searchQuery.toLowerCase()))
// Calculate stats
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) > 5)?.tld || 'ai'
const trapCount = tldData.filter(tld => tld.risk_level === 'high' || tld.risk_level === 'medium').length
const getRiskBadge = (tld: TLDData, blurred: boolean) => {
const { level, reason } = getRiskInfo(tld)
if (blurred) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-foreground/10 blur-[3px] select-none">
🟢 Hidden
</span>
)
}
return (
<span className={clsx(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
level === 'high' && "bg-red-500/10 text-red-400",
level === 'medium' && "bg-amber-500/10 text-amber-400",
level === 'low' && "bg-accent/10 text-accent"
)}>
{level === 'high' && '🔴'}
{level === 'medium' && '🟡'}
{level === 'low' && '🟢'}
<span className="hidden sm:inline ml-1">{reason}</span>
</span>
)
}
const getRenewalTrap = (tld: TLDData) => {
const ratio = tld.min_renewal_price / tld.min_price
if (ratio > 2) {
return (
<span className="inline-flex items-center gap-1 text-amber-400" title={`Renewal is ${ratio.toFixed(1)}x the registration price`}>
<AlertTriangle className="w-3.5 h-3.5" />
</span>
)
}
return null
}
router.replace('/tld-pricing')
}, [router])
return (
<>
<Header />
<main className="min-h-screen bg-background pt-20 sm:pt-24">
{/* Hero Header */}
<section className="relative py-12 sm:py-20 overflow-hidden">
<div className="absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(16,185,129,0.05)_0%,transparent_70%)]" />
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 text-accent text-sm mb-6">
<BarChart3 className="w-4 h-4" />
<span>Real-time Market Data</span>
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-foreground mb-6 tracking-tight">
TLD Pricing
<span className="block text-accent">& Trends</span>
</h1>
<p className="text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto mb-8">
Track prices, renewal traps, and trends across {total > 0 ? total.toLocaleString() : '800+'} TLDs.
Make informed decisions with real market data.
</p>
{/* Stats Cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 max-w-3xl mx-auto mb-8">
<div className="bg-background-secondary/50 border border-border/50 rounded-2xl p-4">
<Globe className="w-5 h-5 text-accent mx-auto mb-2" />
<div className="text-2xl font-bold text-foreground">{total > 0 ? total.toLocaleString() : '—'}</div>
<div className="text-xs text-foreground-muted">TLDs Tracked</div>
</div>
<div className="bg-background-secondary/50 border border-border/50 rounded-2xl p-4">
<DollarSign className="w-5 h-5 text-foreground-muted mx-auto mb-2" />
<div className="text-2xl font-bold text-foreground">{total > 0 ? `$${lowestPrice.toFixed(2)}` : '—'}</div>
<div className="text-xs text-foreground-muted">Lowest Price</div>
</div>
<div className="bg-background-secondary/50 border border-border/50 rounded-2xl p-4">
<TrendingUp className="w-5 h-5 text-orange-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-foreground">.{hottestTld}</div>
<div className="text-xs text-foreground-muted">Hottest TLD</div>
</div>
<div className="bg-background-secondary/50 border border-border/50 rounded-2xl p-4">
<AlertTriangle className="w-5 h-5 text-amber-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-foreground">{trapCount}</div>
<div className="text-xs text-foreground-muted">Renewal Traps</div>
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-foreground-muted">Redirecting to TLD Pricing...</p>
</div>
</div>
</div>
</section>
{/* Main Content */}
<section className="py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-6">
{/* Category Tabs */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
{(Object.keys(CATEGORIES) as CategoryKey[]).map((key) => {
const cat = CATEGORIES[key]
const Icon = cat.icon
return (
<button
key={key}
onClick={() => setCategory(key)}
className={clsx(
"flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium whitespace-nowrap transition-all",
category === key
? "bg-accent/10 text-accent border border-accent/20"
: "bg-foreground/5 text-foreground-muted hover:text-foreground hover:bg-foreground/10 border border-transparent"
)}
>
<Icon className="w-4 h-4" />
{cat.label}
</button>
)
})}
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
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
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="h-11 pl-4 pr-10 bg-background-secondary/50 border border-border/50 rounded-xl
text-sm text-foreground appearance-none cursor-pointer
focus:outline-none focus:border-accent/50"
>
<option value="popularity">By Popularity</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>
<button
onClick={handleRefresh}
disabled={refreshing}
className="flex items-center gap-2 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground
bg-foreground/5 hover:bg-foreground/10 rounded-xl transition-all disabled:opacity-50"
>
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
Refresh
</button>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-4 text-xs text-foreground-muted">
<div className="flex items-center gap-2">
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
<span>Renewal trap: Renewal &gt;2x registration</span>
</div>
<div className="flex items-center gap-2">
<Info className="w-3.5 h-3.5" />
<span>Risk levels: 🟢 Low, 🟡 Medium, 🔴 High</span>
</div>
</div>
{/* TLD Table */}
<div className="relative">
<div className="bg-background-secondary/30 border border-border/50 rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/30">
<th className="px-4 py-3 text-left text-xs font-semibold text-foreground-muted uppercase tracking-wider w-24">TLD</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-foreground-muted uppercase tracking-wider w-20 hidden sm:table-cell">Trend</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-foreground-muted uppercase tracking-wider w-24">Buy (1y)</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-foreground-muted uppercase tracking-wider w-28">Renew (1y)</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-foreground-muted uppercase tracking-wider w-24 hidden md:table-cell">1y Change</th>
<th className="px-4 py-3 text-center text-xs font-semibold text-foreground-muted uppercase tracking-wider w-28">Risk</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-foreground-muted uppercase tracking-wider w-20"></th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 10 }).map((_, i) => (
<tr key={i} className="border-b border-border/20">
<td colSpan={7} className="px-4 py-4">
<div className="h-4 bg-foreground/5 rounded animate-pulse" />
</td>
</tr>
))
) : filteredData.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-foreground-muted">
<Globe className="w-12 h-12 mx-auto mb-3 text-foreground-subtle" />
<p className="font-medium">No TLDs found</p>
<p className="text-sm mt-1">{searchQuery ? `No results for "${searchQuery}"` : 'Check back later'}</p>
</td>
</tr>
) : (
filteredData.map((tld, index) => {
const isBlurred = !isAuthenticated && index >= FREE_VISIBLE_ROWS
const change1y = tld.price_change_1y || 0
return (
<tr
key={tld.tld}
className={clsx(
"border-b border-border/20 group transition-colors",
isBlurred ? "opacity-50" : "hover:bg-foreground/[0.02] cursor-pointer"
)}
onClick={() => !isBlurred && (window.location.href = `/tld-pricing/${tld.tld}`)}
>
<td className="px-4 py-3">
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
.{tld.tld}
</span>
</td>
<td className="px-4 py-3 hidden sm:table-cell">
{isBlurred ? (
<div className="w-10 h-4 bg-foreground/10 rounded blur-[2px]" />
) : (
<Sparkline trend={change1y} />
)}
</td>
<td className="px-4 py-3 text-right">
<span className="font-semibold text-foreground tabular-nums">${tld.min_price.toFixed(2)}</span>
</td>
<td className="px-4 py-3 text-right">
{isBlurred ? (
<span className="text-foreground-muted blur-[3px] select-none tabular-nums">$XX.XX</span>
) : (
<div className="flex items-center gap-1 justify-end">
<span className="text-foreground-muted tabular-nums">
${tld.min_renewal_price.toFixed(2)}
</span>
{getRenewalTrap(tld)}
</div>
)}
</td>
<td className="px-4 py-3 text-right hidden md:table-cell">
{isBlurred ? (
<span className="text-foreground-muted blur-[3px] select-none">+XX%</span>
) : (
<span className={clsx(
"font-medium tabular-nums",
change1y > 0 ? "text-orange-400" : change1y < 0 ? "text-accent" : "text-foreground-muted"
)}>
{change1y > 0 ? '+' : ''}{change1y.toFixed(0)}%
</span>
)}
</td>
<td className="px-4 py-3 text-center">
{getRiskBadge(tld, isBlurred)}
</td>
<td className="px-4 py-3 text-right">
{isBlurred ? (
<Lock className="w-4 h-4 text-foreground-subtle inline" />
) : (
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors inline" />
)}
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
{/* Blur overlay CTA for non-authenticated */}
{!isAuthenticated && filteredData.length > FREE_VISIBLE_ROWS && (
<div className="absolute bottom-0 left-0 right-0 h-48 bg-gradient-to-t from-background via-background/95 to-transparent flex items-end justify-center pb-8 pointer-events-none">
<div className="pointer-events-auto text-center">
<p className="text-foreground-muted mb-4">
Stop overpaying. Unlock renewal prices, trends & risk analysis for {total}+ TLDs.
</p>
<Link
href="/register"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-semibold rounded-xl
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
>
<Sparkles className="w-4 h-4" />
Start Free
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
)}
</div>
{/* Pagination - only for authenticated */}
{isAuthenticated && total > 50 && (
<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 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
</button>
</div>
)}
{/* Login CTA Banner */}
{!isAuthenticated && (
<div className="mt-8 p-6 bg-gradient-to-r from-accent/10 to-accent/5 border border-accent/20 rounded-2xl text-center">
<h3 className="text-xl font-semibold text-foreground mb-2">
🔍 See the Full Picture
</h3>
<p className="text-foreground-muted mb-4 max-w-xl mx-auto">
Unlock renewal traps, 3-year trends, price alerts, and our risk analysis across 800+ TLDs.
Make data-driven domain decisions.
</p>
<div className="flex items-center justify-center gap-3">
<Link
href="/register"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-semibold rounded-xl
hover:bg-accent-hover transition-all"
>
<Sparkles className="w-4 h-4" />
Create Free Account
</Link>
<Link
href="/login"
className="inline-flex items-center gap-2 px-6 py-3 text-foreground-muted hover:text-foreground
border border-border rounded-xl hover:bg-foreground/5 transition-all"
>
Sign In
</Link>
</div>
</div>
)}
</div>
</section>
</main>
<Footer />
</>
)
}

View File

@ -30,8 +30,15 @@ interface TldData {
avg_registration_price: number
min_registration_price: number
max_registration_price: number
min_renewal_price: number
avg_renewal_price: number
registrar_count: number
trend: string
price_change_7d: number
price_change_1y: number
price_change_3y: number
risk_level: 'low' | 'medium' | 'high'
risk_reason: string
popularity_rank?: number
}
@ -409,8 +416,8 @@ export default function TldPricingPage() {
<th className="text-left px-4 sm:px-6 py-4 hidden lg:table-cell">
<span className="text-ui-sm text-foreground-subtle font-medium">Type</span>
</th>
<th className="text-left px-4 sm:px-6 py-4 hidden md:table-cell">
<span className="text-ui-sm text-foreground-subtle font-medium">12-Month Trend</span>
<th className="text-left px-4 sm:px-6 py-4 hidden xl:table-cell">
<span className="text-ui-sm text-foreground-subtle font-medium">12-Month Chart</span>
</th>
<th className="text-right px-4 sm:px-6 py-4">
<button
@ -430,8 +437,14 @@ export default function TldPricingPage() {
<SortIcon field="min_registration_price" currentField={sortField} direction={sortDirection} />
</button>
</th>
<th className="text-right px-4 sm:px-6 py-4 hidden lg:table-cell">
<span className="text-ui-sm text-foreground-subtle font-medium">Renew</span>
</th>
<th className="text-center px-4 sm:px-6 py-4 hidden md:table-cell">
<span className="text-ui-sm text-foreground-subtle font-medium">1y Trend</span>
</th>
<th className="text-center px-4 sm:px-6 py-4 hidden sm:table-cell">
<span className="text-ui-sm text-foreground-subtle font-medium">Trend</span>
<span className="text-ui-sm text-foreground-subtle font-medium">Risk</span>
</th>
<th className="px-4 sm:px-6 py-4"></th>
</tr>
@ -444,16 +457,18 @@ export default function TldPricingPage() {
<td className="px-4 sm:px-6 py-4"><div className="h-4 w-8 bg-background-tertiary rounded" /></td>
<td className="px-4 sm:px-6 py-4"><div className="h-4 w-16 bg-background-tertiary rounded" /></td>
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell"><div className="h-4 w-20 bg-background-tertiary rounded" /></td>
<td className="px-4 sm:px-6 py-4 hidden md:table-cell"><div className="h-10 w-32 bg-background-tertiary rounded" /></td>
<td className="px-4 sm:px-6 py-4 hidden xl:table-cell"><div className="h-10 w-32 bg-background-tertiary rounded" /></td>
<td className="px-4 sm:px-6 py-4"><div className="h-4 w-16 bg-background-tertiary rounded ml-auto" /></td>
<td className="px-4 sm:px-6 py-4 hidden sm:table-cell"><div className="h-4 w-16 bg-background-tertiary rounded ml-auto" /></td>
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell"><div className="h-4 w-16 bg-background-tertiary rounded ml-auto" /></td>
<td className="px-4 sm:px-6 py-4 hidden md:table-cell"><div className="h-4 w-10 bg-background-tertiary rounded mx-auto" /></td>
<td className="px-4 sm:px-6 py-4 hidden sm:table-cell"><div className="h-4 w-6 bg-background-tertiary rounded mx-auto" /></td>
<td className="px-4 sm:px-6 py-4"><div className="h-4 w-12 bg-background-tertiary rounded" /></td>
</tr>
))
) : tlds.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-12 text-center text-foreground-muted">
<td colSpan={10} className="px-6 py-12 text-center text-foreground-muted">
{searchQuery ? `No TLDs found matching "${searchQuery}"` : 'No TLDs found'}
</td>
</tr>
@ -494,7 +509,7 @@ export default function TldPricingPage() {
{tld.type}
</span>
</td>
<td className="px-4 sm:px-6 py-4 hidden md:table-cell">
<td className="px-4 sm:px-6 py-4 hidden xl:table-cell">
<MiniChart tld={tld.tld} isAuthenticated={showFullData} />
</td>
<td className="px-4 sm:px-6 py-4 text-right">
@ -515,8 +530,48 @@ export default function TldPricingPage() {
<span className="text-body-sm text-foreground-subtle"></span>
)}
</td>
<td className="px-4 sm:px-6 py-4 text-right hidden lg:table-cell">
{showFullData ? (
<div className="flex items-center gap-1 justify-end">
<span className="text-body-sm text-foreground-muted">
${tld.min_renewal_price?.toFixed(2) || '—'}
</span>
{tld.min_renewal_price && tld.min_renewal_price / tld.min_registration_price > 2 && (
<span className="text-amber-400 text-xs" title="Renewal trap: >2x registration"></span>
)}
</div>
) : (
<span className="text-body-sm text-foreground-subtle blur-[3px]">$XX.XX</span>
)}
</td>
<td className="px-4 sm:px-6 py-4 text-center hidden md:table-cell">
{showFullData ? (
<span className={clsx(
"text-body-sm font-medium",
(tld.price_change_1y || 0) > 0 ? "text-[#f97316]" :
(tld.price_change_1y || 0) < 0 ? "text-accent" : "text-foreground-muted"
)}>
{(tld.price_change_1y || 0) > 0 ? '+' : ''}{(tld.price_change_1y || 0).toFixed(0)}%
</span>
) : (
<span className="text-body-sm text-foreground-subtle blur-[3px]">+X%</span>
)}
</td>
<td className="px-4 sm:px-6 py-4 text-center hidden sm:table-cell">
{showFullData ? getTrendIcon(tld.trend) : <Minus className="w-4 h-4 text-foreground-subtle mx-auto" />}
{showFullData ? (
<span className={clsx(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
tld.risk_level === 'high' && "bg-red-500/10 text-red-400",
tld.risk_level === 'medium' && "bg-amber-500/10 text-amber-400",
tld.risk_level === 'low' && "bg-accent/10 text-accent"
)}>
{tld.risk_level === 'high' && '🔴'}
{tld.risk_level === 'medium' && '🟡'}
{tld.risk_level === 'low' && '🟢'}
</span>
) : (
<span className="text-foreground-subtle blur-[3px]">🟢</span>
)}
</td>
<td className="px-4 sm:px-6 py-4">
<Link