feat: INTEL - Complete Redesign (Award-Winning Style)
Changes:
- Rebuilt INTEL (Analytics) page to match Market/Radar style
- Features:
- 'Emerald Glow' background effect
- High-end Stat Cards grid
- Integrated header with 'Pill' style filters
- Advanced Data Table with:
- Renewal Trap warnings (Amber alert if >1.5x reg price)
- Trend indicators (Sparklines/Arrows)
- Risk Level meters (Visual bars)
- Mobile Optimization:
- Elegant Card layout for small screens
- Touch-friendly controls
This commit is contained in:
@ -1,34 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { TerminalLayout } from '@/components/TerminalLayout'
|
||||
import {
|
||||
PremiumTable,
|
||||
StatCard,
|
||||
PageContainer,
|
||||
SearchInput,
|
||||
TabBar,
|
||||
FilterBar,
|
||||
SelectDropdown,
|
||||
ActionButton,
|
||||
} from '@/components/PremiumTable'
|
||||
import {
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
TrendingUp,
|
||||
ChevronRight,
|
||||
TrendingDown,
|
||||
Globe,
|
||||
DollarSign,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
Cpu,
|
||||
MapPin,
|
||||
Coins,
|
||||
Crown,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Info,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
PieChart
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
// ============================================================================
|
||||
// SHARED COMPONENTS (Matching Market/Radar Style)
|
||||
// ============================================================================
|
||||
|
||||
function Tooltip({ children, content }: { children: React.ReactNode; content: string }) {
|
||||
return (
|
||||
<div className="relative flex items-center group">
|
||||
{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:opacity-100 transition-opacity pointer-events-none z-50 shadow-xl">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
icon: Icon,
|
||||
trend
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
subValue?: string
|
||||
icon: any
|
||||
trend?: 'up' | 'down' | 'neutral' | 'active'
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-zinc-900/40 border border-white/5 rounded-xl p-4 flex items-start justify-between hover:bg-white/[0.02] transition-colors relative overflow-hidden group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="relative z-10">
|
||||
<p className="text-[11px] font-semibold text-zinc-500 uppercase tracking-wider mb-1">{label}</p>
|
||||
<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>
|
||||
</div>
|
||||
<div className={clsx(
|
||||
"relative z-10 p-2 rounded-lg bg-zinc-800/50 transition-colors",
|
||||
trend === 'up' && "text-emerald-400 bg-emerald-500/10",
|
||||
trend === 'down' && "text-red-400 bg-red-500/10",
|
||||
trend === 'active' && "text-emerald-400 bg-emerald-500/10 animate-pulse",
|
||||
trend === 'neutral' && "text-zinc-400"
|
||||
)}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterToggle({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
"px-4 py-1.5 rounded-full text-xs font-medium transition-all border whitespace-nowrap",
|
||||
active
|
||||
? "bg-white text-black border-white shadow-[0_0_10px_rgba(255,255,255,0.1)]"
|
||||
: "bg-transparent text-zinc-400 border-zinc-800 hover:border-zinc-700 hover:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
label, field, currentSort, currentDirection, onSort, align = 'left', tooltip
|
||||
}: {
|
||||
label: string; field: SortField; currentSort: SortField; currentDirection: SortDirection; onSort: (field: SortField) => void; align?: 'left'|'center'|'right'; tooltip?: 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={() => onSort(field)}
|
||||
className={clsx(
|
||||
"flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest transition-all group select-none py-2",
|
||||
isActive ? "text-white" : "text-zinc-500 hover:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<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-white" : "text-zinc-600")} />
|
||||
<ChevronDown className={clsx("w-2 h-2", isActive && currentDirection === 'desc' ? "text-white" : "text-zinc-600")} />
|
||||
</div>
|
||||
</button>
|
||||
{tooltip && (
|
||||
<Tooltip content={tooltip}>
|
||||
<Info className="w-3 h-3 text-zinc-700 hover:text-zinc-500 transition-colors cursor-help" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface TLDData {
|
||||
tld: string
|
||||
@ -41,93 +142,42 @@ interface TLDData {
|
||||
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 = [
|
||||
{ id: 'all', label: 'All', icon: Globe },
|
||||
{ id: 'tech', label: 'Tech', icon: Cpu },
|
||||
{ id: 'geo', label: 'Geo', icon: MapPin },
|
||||
{ id: 'budget', label: 'Budget', icon: Coins },
|
||||
{ id: 'premium', label: 'Premium', icon: Crown },
|
||||
]
|
||||
type SortField = 'tld' | 'price' | 'change' | 'risk' | 'popularity'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
const CATEGORY_FILTERS: Record<string, (tld: TLDData) => boolean> = {
|
||||
all: () => true,
|
||||
tech: (tld) => ['ai', 'io', 'app', 'dev', 'tech', 'code', 'cloud', 'data', 'api', 'software'].includes(tld.tld),
|
||||
geo: (tld) => ['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: (tld) => tld.min_price < 5,
|
||||
premium: (tld) => tld.min_price >= 50,
|
||||
}
|
||||
// ============================================================================
|
||||
// MAIN PAGE
|
||||
// ============================================================================
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'popularity', label: 'By Popularity' },
|
||||
{ value: 'price_asc', label: 'Price: Low → High' },
|
||||
{ value: 'price_desc', label: 'Price: High → Low' },
|
||||
{ value: 'change', label: 'By Price Change' },
|
||||
{ value: 'risk', label: 'By Risk Level' },
|
||||
]
|
||||
|
||||
// Memoized Sparkline
|
||||
const Sparkline = memo(function Sparkline({ trend }: { trend: number }) {
|
||||
const isPositive = trend > 0
|
||||
const isNeutral = trend === 0
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
})
|
||||
|
||||
export default function TLDPricingPage() {
|
||||
export default function IntelPage() {
|
||||
const { subscription } = useStore()
|
||||
|
||||
// Data
|
||||
const [tldData, setTldData] = useState<TLDData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState('popularity')
|
||||
const [category, setCategory] = useState('all')
|
||||
const [page, setPage] = useState(0)
|
||||
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')
|
||||
|
||||
const loadTLDData = useCallback(async () => {
|
||||
// Load Data
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await api.getTldOverview(
|
||||
50,
|
||||
page * 50,
|
||||
sortBy === 'risk' || sortBy === 'change' ? 'popularity' : sortBy as any,
|
||||
)
|
||||
const mapped: TLDData[] = (response.tlds || []).map((tld) => ({
|
||||
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,
|
||||
@ -149,261 +199,266 @@ export default function TLDPricingPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, sortBy])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadTLDData()
|
||||
}, [loadTLDData])
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await loadTLDData()
|
||||
await loadData()
|
||||
setRefreshing(false)
|
||||
}, [loadTLDData])
|
||||
}, [loadData])
|
||||
|
||||
// Memoized filtered and sorted data
|
||||
const sortedData = useMemo(() => {
|
||||
let data = tldData.filter(CATEGORY_FILTERS[category] || (() => true))
|
||||
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (sortField === field) setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
|
||||
else {
|
||||
setSortField(field)
|
||||
setSortDirection(field === 'price' || field === 'risk' ? 'asc' : 'desc')
|
||||
}
|
||||
}, [sortField])
|
||||
|
||||
// Transform & Filter
|
||||
const filteredData = useMemo(() => {
|
||||
let data = tldData
|
||||
|
||||
// Category Filter
|
||||
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)
|
||||
|
||||
// Search
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
data = data.filter(tld => tld.tld.toLowerCase().includes(q))
|
||||
data = data.filter(t => t.tld.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
}
|
||||
|
||||
if (sortBy === 'risk') {
|
||||
const riskOrder = { high: 0, medium: 1, low: 2 }
|
||||
data = [...data].sort((a, b) => riskOrder[a.risk_level] - riskOrder[b.risk_level])
|
||||
}
|
||||
|
||||
return data
|
||||
}, [tldData, category, searchQuery, sortBy])
|
||||
|
||||
// Memoized stats
|
||||
// Sort
|
||||
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 'change': return mult * ((a.price_change_1y || 0) - (b.price_change_1y || 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])
|
||||
|
||||
// Stats
|
||||
const stats = useMemo(() => {
|
||||
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
|
||||
return { lowestPrice, hottestTld, trapCount }
|
||||
const lowest = tldData.length > 0 ? Math.min(...tldData.map(t => t.min_price)) : 0
|
||||
const hottest = tldData.reduce((prev, current) => (prev.price_change_7d > current.price_change_7d) ? prev : current, tldData[0] || {})
|
||||
const traps = tldData.filter(t => t.risk_level === 'high').length
|
||||
return { lowest, hottest, traps }
|
||||
}, [tldData])
|
||||
|
||||
const subtitle = useMemo(() => {
|
||||
if (loading && total === 0) return 'Loading TLD pricing data...'
|
||||
if (total === 0) return 'No TLD data available'
|
||||
return `Tracking ${total.toLocaleString()} TLDs • Updated daily`
|
||||
}, [loading, total])
|
||||
|
||||
// Memoized columns
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
key: 'tld',
|
||||
header: 'TLD',
|
||||
width: '100px',
|
||||
render: (tld: TLDData) => (
|
||||
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||
.{tld.tld}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'trend',
|
||||
header: 'Trend',
|
||||
width: '80px',
|
||||
hideOnMobile: true,
|
||||
render: (tld: TLDData) => <Sparkline trend={tld.price_change_1y || 0} />,
|
||||
},
|
||||
{
|
||||
key: 'buy_price',
|
||||
header: 'Buy (1y)',
|
||||
align: 'right' as const,
|
||||
width: '100px',
|
||||
render: (tld: TLDData) => (
|
||||
<span className="font-semibold text-foreground tabular-nums">${tld.min_price.toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'renew_price',
|
||||
header: 'Renew (1y)',
|
||||
align: 'right' as const,
|
||||
width: '120px',
|
||||
render: (tld: TLDData) => {
|
||||
const ratio = tld.min_renewal_price / tld.min_price
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<span className="text-foreground-muted tabular-nums">${tld.min_renewal_price.toFixed(2)}</span>
|
||||
{ratio > 2 && (
|
||||
<span className="text-amber-400" title={`Renewal is ${ratio.toFixed(1)}x registration`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'change_1y',
|
||||
header: '1y',
|
||||
align: 'right' as const,
|
||||
width: '80px',
|
||||
hideOnMobile: true,
|
||||
render: (tld: TLDData) => {
|
||||
const change = tld.price_change_1y || 0
|
||||
return (
|
||||
<span className={clsx("font-medium tabular-nums", change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted")}>
|
||||
{change > 0 ? '+' : ''}{change.toFixed(0)}%
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'change_3y',
|
||||
header: '3y',
|
||||
align: 'right' as const,
|
||||
width: '80px',
|
||||
hideOnMobile: true,
|
||||
render: (tld: TLDData) => {
|
||||
const change = tld.price_change_3y || 0
|
||||
return (
|
||||
<span className={clsx("font-medium tabular-nums", change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted")}>
|
||||
{change > 0 ? '+' : ''}{change.toFixed(0)}%
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'cheapest',
|
||||
header: 'Cheapest At',
|
||||
align: 'left' as const,
|
||||
width: '140px',
|
||||
hideOnMobile: true,
|
||||
render: (tld: TLDData) => (
|
||||
tld.cheapest_registrar ? (
|
||||
<a
|
||||
href={tld.cheapest_registrar_url || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs text-accent hover:text-accent/80 hover:underline transition-colors"
|
||||
>
|
||||
{tld.cheapest_registrar}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xs text-foreground-subtle">—</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'risk',
|
||||
header: 'Risk',
|
||||
align: 'center' as const,
|
||||
width: '120px',
|
||||
render: (tld: TLDData) => (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-1.5 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"
|
||||
)}>
|
||||
<span className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
tld.risk_level === 'high' && "bg-red-400",
|
||||
tld.risk_level === 'medium' && "bg-amber-400",
|
||||
tld.risk_level === 'low' && "bg-accent"
|
||||
)} />
|
||||
<span className="hidden sm:inline">{tld.risk_reason}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right' as const,
|
||||
width: '50px',
|
||||
render: () => <ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />,
|
||||
},
|
||||
], [])
|
||||
const formatPrice = (p: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(p)
|
||||
|
||||
return (
|
||||
<TerminalLayout
|
||||
title="Intel"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<ActionButton onClick={handleRefresh} disabled={refreshing} variant="ghost" icon={refreshing ? Loader2 : RefreshCw}>
|
||||
{refreshing ? '' : 'Refresh'}
|
||||
</ActionButton>
|
||||
}
|
||||
<TerminalLayout
|
||||
title="Intel"
|
||||
subtitle="TLD Analytics & Pricing Data"
|
||||
hideHeaderSearch={true}
|
||||
>
|
||||
<PageContainer>
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="TLDs Tracked" value={total > 0 ? total.toLocaleString() : '—'} subtitle="updated daily" icon={Globe} />
|
||||
<StatCard title="Lowest Price" value={total > 0 ? `$${stats.lowestPrice.toFixed(2)}` : '—'} icon={DollarSign} />
|
||||
<StatCard title="Hottest TLD" value={total > 0 ? `.${stats.hottestTld}` : '—'} subtitle="rising prices" icon={TrendingUp} />
|
||||
<StatCard title="Renewal Traps" value={stats.trapCount.toString()} subtitle="high renewal ratio" icon={AlertTriangle} />
|
||||
<div className="relative">
|
||||
{/* Glow Effect */}
|
||||
<div className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute -top-72 right-0 w-[800px] h-[800px] bg-emerald-500/5 blur-[120px] rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<TabBar
|
||||
tabs={CATEGORIES.map(c => ({ id: c.id, label: c.label, icon: c.icon }))}
|
||||
activeTab={category}
|
||||
onChange={setCategory}
|
||||
/>
|
||||
<div className="space-y-6 pb-20 md:pb-0 relative">
|
||||
|
||||
{/* METRICS */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Tracked TLDs" value={total} icon={Globe} trend="neutral" />
|
||||
<StatCard label="Lowest Entry" value={formatPrice(stats.lowest)} subValue="registration" icon={DollarSign} trend="up" />
|
||||
<StatCard label="Top Mover" value={stats.hottest?.tld ? `.${stats.hottest.tld}` : '-'} subValue={`${stats.hottest?.price_change_7d > 0 ? '+' : ''}${stats.hottest?.price_change_7d}% (7d)`} icon={TrendingUp} trend="active" />
|
||||
<StatCard label="Renewal Traps" value={stats.traps} subValue="High Risk" icon={AlertTriangle} trend="down" />
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search TLDs (e.g. com, io, dev)..."
|
||||
className="flex-1 max-w-md"
|
||||
/>
|
||||
<SelectDropdown value={sortBy} onChange={setSortBy} options={SORT_OPTIONS} />
|
||||
</FilterBar>
|
||||
{/* CONTROLS */}
|
||||
<div className="sticky top-0 z-30 bg-zinc-950/80 backdrop-blur-md py-4 border-b border-white/5 -mx-4 px-4 md:mx-0 md:px-0 md:border-none md:bg-transparent md:static">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative w-full md:w-80 group">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500 group-focus-within:text-white transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search TLDs (e.g. .io)..."
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-zinc-900 border border-white/10 rounded-xl
|
||||
text-sm text-white placeholder:text-zinc-600
|
||||
focus:outline-none focus:border-white/20 focus:ring-1 focus:ring-white/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto w-full pb-1 md:pb-0 scrollbar-hide mask-fade-right">
|
||||
<FilterToggle active={filterType === 'all'} onClick={() => setFilterType('all')} label="All TLDs" />
|
||||
<FilterToggle active={filterType === 'tech'} onClick={() => setFilterType('tech')} label="Tech" />
|
||||
<FilterToggle active={filterType === 'geo'} onClick={() => setFilterType('geo')} label="Geo / National" />
|
||||
<FilterToggle active={filterType === 'budget'} onClick={() => setFilterType('budget')} label="Budget <$10" />
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-foreground-muted">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<span>Tip: Renewal traps show ⚠️ when renewal price is >2x registration</span>
|
||||
<div className="hidden md:block flex-1" />
|
||||
|
||||
<button onClick={handleRefresh} className="hidden md:flex items-center gap-2 text-xs font-medium text-zinc-500 hover:text-white transition-colors">
|
||||
<RefreshCw className={clsx("w-3.5 h-3.5", refreshing && "animate-spin")} />
|
||||
Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DATA GRID */}
|
||||
<div className="min-h-[400px]">
|
||||
{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-32 text-center">
|
||||
<div className="w-16 h-16 bg-zinc-900 rounded-full flex items-center justify-center mb-4 border border-zinc-800">
|
||||
<Search className="w-6 h-6 text-zinc-600" />
|
||||
</div>
|
||||
<h3 className="text-white font-medium mb-1">No TLDs found</h3>
|
||||
<p className="text-zinc-500 text-sm">Try adjusting your filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* DESKTOP TABLE */}
|
||||
<div className="hidden md:block border border-white/5 rounded-xl overflow-hidden bg-zinc-900/40 backdrop-blur-sm shadow-xl">
|
||||
<div className="grid grid-cols-12 gap-4 px-6 py-3 border-b border-white/5 bg-white/[0.02]">
|
||||
<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" tooltip="Cheapest registration price found" /></div>
|
||||
<div className="col-span-2 text-right"><SortableHeader label="Renewal" field="price" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="right" tooltip="Estimated annual renewal cost" /></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"><SortableHeader label="Risk Level" field="risk" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" tooltip="Risk of price hikes or restrictions" /></div>
|
||||
<div className="col-span-2 text-right"><span className="text-[10px] font-bold uppercase tracking-widest text-zinc-600 py-2 block">Provider</span></div>
|
||||
</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
|
||||
|
||||
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">
|
||||
<span className="font-mono font-bold text-white text-lg">.{tld.tld}</span>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="col-span-2 text-right">
|
||||
<span className="font-mono text-white font-medium">{formatPrice(tld.min_price)}</span>
|
||||
</div>
|
||||
|
||||
{/* Renewal */}
|
||||
<div className="col-span-2 text-right flex items-center justify-end gap-2">
|
||||
<span className={clsx("font-mono text-sm", 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 than registration!`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 cursor-help" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trend */}
|
||||
<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",
|
||||
trend > 5 ? "bg-orange-500/10 text-orange-400" :
|
||||
trend < -5 ? "bg-emerald-500/10 text-emerald-400" :
|
||||
"text-zinc-500"
|
||||
)}>
|
||||
{trend > 0 ? <TrendingUp className="w-3 h-3" /> : trend < 0 ? <TrendingDown className="w-3 h-3" /> : null}
|
||||
{Math.abs(trend)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk */}
|
||||
<div className="col-span-2 flex justify-center">
|
||||
<Tooltip content={tld.risk_reason || 'Standard risk profile'}>
|
||||
<div className={clsx("w-20 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>
|
||||
|
||||
{/* Provider */}
|
||||
<div className="col-span-2 text-right">
|
||||
{tld.cheapest_registrar ? (
|
||||
<a href={tld.cheapest_registrar_url || '#'} target="_blank" className="text-xs text-zinc-500 hover:text-white transition-colors">
|
||||
{tld.cheapest_registrar} <ExternalLink className="w-3 h-3 inline ml-0.5" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MOBILE CARDS */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{filteredData.map((tld) => {
|
||||
const isTrap = tld.min_renewal_price > tld.min_price * 1.5
|
||||
return (
|
||||
<div key={tld.tld} className="bg-zinc-900/40 border border-white/5 rounded-xl p-4 active:bg-zinc-900/60 transition-colors">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<span className="font-mono font-bold text-white text-xl">.{tld.tld}</span>
|
||||
<div className={clsx("px-2 py-1 rounded text-[10px] uppercase font-bold",
|
||||
tld.risk_level === 'low' ? "bg-emerald-500/10 text-emerald-400" :
|
||||
tld.risk_level === 'medium' ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-red-500/10 text-red-400"
|
||||
)}>
|
||||
{tld.risk_level} Risk
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-3">
|
||||
<div>
|
||||
<div className="text-[10px] text-zinc-500 uppercase tracking-wider mb-0.5">Register</div>
|
||||
<div className="font-mono text-lg font-medium text-white">{formatPrice(tld.min_price)}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-zinc-500 uppercase tracking-wider mb-0.5">Renew</div>
|
||||
<div className={clsx("font-mono text-lg font-medium", isTrap ? "text-amber-400" : "text-zinc-400")}>
|
||||
{formatPrice(tld.min_renewal_price)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tld.cheapest_registrar && (
|
||||
<div className="pt-3 border-t border-white/5 text-center">
|
||||
<span className="text-xs text-zinc-500">Best price at </span>
|
||||
<a href={tld.cheapest_registrar_url || '#'} className="text-xs text-white font-medium hover:underline">
|
||||
{tld.cheapest_registrar}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TLD Table */}
|
||||
<PremiumTable
|
||||
data={sortedData}
|
||||
keyExtractor={(tld) => tld.tld}
|
||||
loading={loading}
|
||||
onRowClick={(tld) => window.location.href = `/terminal/intel/${tld.tld}`}
|
||||
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />}
|
||||
emptyTitle="No TLDs found"
|
||||
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
|
||||
columns={columns}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{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>
|
||||
)}
|
||||
</PageContainer>
|
||||
</div>
|
||||
</TerminalLayout>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user