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
689 lines
30 KiB
TypeScript
689 lines
30 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||
import { Header } from '@/components/Header'
|
||
import { Footer } from '@/components/Footer'
|
||
import { useStore } from '@/lib/store'
|
||
import { api } from '@/lib/api'
|
||
import {
|
||
TrendingUp,
|
||
TrendingDown,
|
||
Minus,
|
||
ArrowRight,
|
||
BarChart3,
|
||
ChevronUp,
|
||
ChevronDown,
|
||
ChevronsUpDown,
|
||
Lock,
|
||
ChevronRight,
|
||
ChevronLeft,
|
||
Search,
|
||
X,
|
||
} from 'lucide-react'
|
||
import Link from 'next/link'
|
||
import clsx from 'clsx'
|
||
|
||
interface TldData {
|
||
tld: string
|
||
type: string
|
||
description: string
|
||
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
|
||
}
|
||
|
||
interface TrendingTld {
|
||
tld: string
|
||
reason: string
|
||
price_change: number
|
||
current_price: number
|
||
}
|
||
|
||
interface PaginationData {
|
||
total: number
|
||
limit: number
|
||
offset: number
|
||
has_more: boolean
|
||
}
|
||
|
||
type SortField = 'popularity' | 'tld' | 'avg_registration_price' | 'min_registration_price'
|
||
type SortDirection = 'asc' | 'desc'
|
||
|
||
// Mini sparkline chart component
|
||
function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boolean }) {
|
||
const [historyData, setHistoryData] = useState<number[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
useEffect(() => {
|
||
if (isAuthenticated) {
|
||
loadHistory()
|
||
} else {
|
||
setLoading(false)
|
||
}
|
||
}, [tld, isAuthenticated])
|
||
|
||
const loadHistory = async () => {
|
||
try {
|
||
const data = await api.getTldHistory(tld, 365)
|
||
const history = data.history || []
|
||
const sampledData = history
|
||
.filter((_: unknown, i: number) => i % Math.max(1, Math.floor(history.length / 12)) === 0)
|
||
.slice(0, 12)
|
||
.map((h: { price: number }) => h.price)
|
||
|
||
setHistoryData(sampledData.length > 0 ? sampledData : [])
|
||
} catch (error) {
|
||
console.error('Failed to load history:', error)
|
||
setHistoryData([])
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
if (!isAuthenticated) {
|
||
return (
|
||
<div className="flex items-center gap-2 text-ui-sm text-foreground-subtle">
|
||
<Lock className="w-3 h-3" />
|
||
<span>Sign in</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (loading) {
|
||
return <div className="w-32 h-10 bg-background-tertiary rounded animate-pulse" />
|
||
}
|
||
|
||
if (historyData.length === 0) {
|
||
return <div className="w-32 h-10 flex items-center justify-center text-ui-sm text-foreground-subtle">No data</div>
|
||
}
|
||
|
||
const min = Math.min(...historyData)
|
||
const max = Math.max(...historyData)
|
||
const range = max - min || 1
|
||
const isIncreasing = historyData[historyData.length - 1] > historyData[0]
|
||
|
||
const linePoints = historyData.map((value, i) => {
|
||
const x = (i / (historyData.length - 1)) * 100
|
||
const y = 100 - ((value - min) / range) * 80 - 10
|
||
return `${x},${y}`
|
||
}).join(' ')
|
||
|
||
const areaPath = historyData.map((value, i) => {
|
||
const x = (i / (historyData.length - 1)) * 100
|
||
const y = 100 - ((value - min) / range) * 80 - 10
|
||
return i === 0 ? `M${x},${y}` : `L${x},${y}`
|
||
}).join(' ') + ' L100,100 L0,100 Z'
|
||
|
||
const gradientId = `gradient-${tld}`
|
||
|
||
return (
|
||
<svg className="w-32 h-10" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||
<defs>
|
||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
|
||
<stop offset="0%" stopColor={isIncreasing ? "#f97316" : "#00d4aa"} stopOpacity="0.3" />
|
||
<stop offset="100%" stopColor={isIncreasing ? "#f97316" : "#00d4aa"} stopOpacity="0.02" />
|
||
</linearGradient>
|
||
</defs>
|
||
<path d={areaPath} fill={`url(#${gradientId})`} />
|
||
<polyline
|
||
points={linePoints}
|
||
fill="none"
|
||
strokeWidth="2.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
className={isIncreasing ? "stroke-[#f97316]" : "stroke-accent"}
|
||
/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function SortIcon({ field, currentField, direction }: { field: SortField, currentField: SortField, direction: SortDirection }) {
|
||
if (field !== currentField) {
|
||
return <ChevronsUpDown className="w-4 h-4 text-foreground-subtle" />
|
||
}
|
||
return direction === 'asc'
|
||
? <ChevronUp className="w-4 h-4 text-accent" />
|
||
: <ChevronDown className="w-4 h-4 text-accent" />
|
||
}
|
||
|
||
export default function TldPricingPage() {
|
||
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
||
const [tlds, setTlds] = useState<TldData[]>([])
|
||
const [trending, setTrending] = useState<TrendingTld[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [pagination, setPagination] = useState<PaginationData>({ total: 0, limit: 25, offset: 0, has_more: false })
|
||
|
||
// Search & Sort state
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||
const [sortField, setSortField] = useState<SortField>('popularity')
|
||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||
|
||
// Debounce search
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
setDebouncedSearch(searchQuery)
|
||
}, 300)
|
||
return () => clearTimeout(timer)
|
||
}, [searchQuery])
|
||
|
||
useEffect(() => {
|
||
checkAuth()
|
||
loadTrending()
|
||
}, [checkAuth])
|
||
|
||
// Load TLDs with pagination, search, and sort
|
||
useEffect(() => {
|
||
loadTlds()
|
||
}, [debouncedSearch, sortField, sortDirection, pagination.offset])
|
||
|
||
const loadTlds = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const sortBy = sortField === 'tld' ? 'name' : sortField === 'popularity' ? 'popularity' :
|
||
sortField === 'avg_registration_price' ? (sortDirection === 'asc' ? 'price_asc' : 'price_desc') :
|
||
(sortDirection === 'asc' ? 'price_asc' : 'price_desc')
|
||
|
||
const data = await api.getTldOverview(
|
||
pagination.limit,
|
||
pagination.offset,
|
||
sortBy,
|
||
debouncedSearch || undefined
|
||
)
|
||
|
||
setTlds(data?.tlds || [])
|
||
setPagination(prev => ({
|
||
...prev,
|
||
total: data?.total || 0,
|
||
has_more: data?.has_more || false,
|
||
}))
|
||
} catch (error) {
|
||
console.error('Failed to load TLD data:', error)
|
||
setTlds([])
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadTrending = async () => {
|
||
try {
|
||
const data = await api.getTrendingTlds()
|
||
setTrending(data?.trending || [])
|
||
} catch (error) {
|
||
console.error('Failed to load trending:', error)
|
||
}
|
||
}
|
||
|
||
const handleSort = (field: SortField) => {
|
||
if (sortField === field) {
|
||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||
} else {
|
||
setSortField(field)
|
||
setSortDirection('asc')
|
||
}
|
||
// Reset to first page on sort change
|
||
setPagination(prev => ({ ...prev, offset: 0 }))
|
||
}
|
||
|
||
const handlePageChange = (newOffset: number) => {
|
||
setPagination(prev => ({ ...prev, offset: newOffset }))
|
||
// Scroll to top of table
|
||
window.scrollTo({ top: 300, behavior: 'smooth' })
|
||
}
|
||
|
||
const getTrendIcon = (trend: string) => {
|
||
switch (trend) {
|
||
case 'up':
|
||
return <TrendingUp className="w-4 h-4 text-[#f97316]" />
|
||
case 'down':
|
||
return <TrendingDown className="w-4 h-4 text-accent" />
|
||
default:
|
||
return <Minus className="w-4 h-4 text-foreground-subtle" />
|
||
}
|
||
}
|
||
|
||
// Pagination calculations
|
||
const currentPage = Math.floor(pagination.offset / pagination.limit) + 1
|
||
const totalPages = Math.ceil(pagination.total / pagination.limit)
|
||
|
||
if (authLoading) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center">
|
||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-background relative overflow-hidden">
|
||
{/* Background Effects - matching landing page */}
|
||
<div className="fixed inset-0 pointer-events-none">
|
||
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||
<div
|
||
className="absolute inset-0 opacity-[0.015]"
|
||
style={{
|
||
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
|
||
backgroundSize: '64px 64px',
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<Header />
|
||
|
||
<main className="relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 flex-1">
|
||
<div className="max-w-7xl mx-auto">
|
||
{/* Header */}
|
||
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
|
||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Market Intel</span>
|
||
<h1 className="mt-4 font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] lg:text-[5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
|
||
{pagination.total}+ TLDs. Live Prices.
|
||
</h1>
|
||
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
||
See what domains cost. Spot trends. Find opportunities.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Login Banner for non-authenticated users */}
|
||
{!isAuthenticated && (
|
||
<div className="mb-8 p-5 bg-accent-muted border border-accent/20 rounded-xl flex flex-col sm:flex-row items-center justify-between gap-4 animate-fade-in">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 bg-accent/20 rounded-xl flex items-center justify-center">
|
||
<Lock className="w-5 h-5 text-accent" />
|
||
</div>
|
||
<div>
|
||
<p className="text-body-sm font-medium text-foreground">See the full picture</p>
|
||
<p className="text-ui-sm text-foreground-muted">
|
||
Sign in for detailed pricing, charts, and trends.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Link
|
||
href="/register"
|
||
className="shrink-0 px-5 py-2.5 bg-accent text-background text-ui font-medium rounded-lg
|
||
hover:bg-accent-hover transition-all duration-300"
|
||
>
|
||
Hunt Free
|
||
</Link>
|
||
</div>
|
||
)}
|
||
|
||
{/* Trending Section */}
|
||
{trending.length > 0 && (
|
||
<div className="mb-12 sm:mb-16 animate-slide-up">
|
||
<h2 className="text-body-lg sm:text-heading-sm font-medium text-foreground mb-4 sm:mb-6 flex items-center gap-2">
|
||
<TrendingUp className="w-5 h-5 text-accent" />
|
||
Moving Now
|
||
</h2>
|
||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||
{trending.map((item) => (
|
||
<Link
|
||
key={item.tld}
|
||
href={isAuthenticated ? `/tld-pricing/${item.tld}` : '/register'}
|
||
className="p-4 sm:p-5 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover hover:bg-background-secondary transition-all duration-300 text-left group"
|
||
>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<span className="font-mono text-body-lg sm:text-heading-sm text-foreground">.{item.tld}</span>
|
||
<span className={clsx(
|
||
"text-ui-sm font-medium px-2 py-0.5 rounded-full",
|
||
item.price_change > 0
|
||
? "text-[#f97316] bg-[#f9731615]"
|
||
: "text-accent bg-accent-muted"
|
||
)}>
|
||
{item.price_change > 0 ? '+' : ''}{item.price_change.toFixed(1)}%
|
||
</span>
|
||
</div>
|
||
<p className="text-body-sm text-foreground-muted mb-2 line-clamp-2">
|
||
{item.reason}
|
||
</p>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-body-sm text-foreground-subtle">
|
||
${item.current_price.toFixed(2)}/yr
|
||
</span>
|
||
<ChevronRight className="w-4 h-4 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Search Bar */}
|
||
<div className="mb-6 animate-slide-up">
|
||
<div className="relative max-w-md">
|
||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
|
||
<input
|
||
type="text"
|
||
placeholder="Search TLDs (e.g., com, io, ai)..."
|
||
value={searchQuery}
|
||
onChange={(e) => {
|
||
setSearchQuery(e.target.value)
|
||
setPagination(prev => ({ ...prev, offset: 0 }))
|
||
}}
|
||
className="w-full pl-12 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||
text-body text-foreground placeholder:text-foreground-subtle
|
||
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent
|
||
transition-all duration-300"
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
onClick={() => {
|
||
setSearchQuery('')
|
||
setPagination(prev => ({ ...prev, offset: 0 }))
|
||
}}
|
||
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-foreground-subtle hover:text-foreground transition-colors"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* TLD Table */}
|
||
<div className="bg-background-secondary/30 border border-border rounded-xl overflow-hidden animate-slide-up">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="bg-background-secondary border-b border-border">
|
||
<th className="text-left px-4 sm:px-6 py-4">
|
||
<button
|
||
onClick={() => handleSort('popularity')}
|
||
className="flex items-center gap-2 text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||
>
|
||
#
|
||
<SortIcon field="popularity" currentField={sortField} direction={sortDirection} />
|
||
</button>
|
||
</th>
|
||
<th className="text-left px-4 sm:px-6 py-4">
|
||
<button
|
||
onClick={() => handleSort('tld')}
|
||
className="flex items-center gap-2 text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||
>
|
||
TLD
|
||
<SortIcon field="tld" currentField={sortField} direction={sortDirection} />
|
||
</button>
|
||
</th>
|
||
<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 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
|
||
onClick={() => handleSort('avg_registration_price')}
|
||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||
>
|
||
Avg. Price
|
||
<SortIcon field="avg_registration_price" currentField={sortField} direction={sortDirection} />
|
||
</button>
|
||
</th>
|
||
<th className="text-right px-4 sm:px-6 py-4 hidden sm:table-cell">
|
||
<button
|
||
onClick={() => handleSort('min_registration_price')}
|
||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||
>
|
||
From
|
||
<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">Risk</span>
|
||
</th>
|
||
<th className="px-4 sm:px-6 py-4"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{loading ? (
|
||
// Loading skeleton
|
||
Array.from({ length: 10 }).map((_, idx) => (
|
||
<tr key={idx} className="animate-pulse">
|
||
<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 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={10} className="px-6 py-12 text-center text-foreground-muted">
|
||
{searchQuery ? `No TLDs found matching "${searchQuery}"` : 'No TLDs found'}
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
tlds.map((tld, idx) => {
|
||
// Show full data for authenticated users OR for the first row (idx 0 on first page)
|
||
// This lets visitors see how good the data is for .com before signing up
|
||
const showFullData = isAuthenticated || (pagination.offset === 0 && idx === 0)
|
||
|
||
return (
|
||
<tr
|
||
key={tld.tld}
|
||
className={clsx(
|
||
"hover:bg-background-secondary/50 transition-colors group",
|
||
!isAuthenticated && idx === 0 && pagination.offset === 0 && "bg-accent/5"
|
||
)}
|
||
>
|
||
<td className="px-4 sm:px-6 py-4">
|
||
<span className="text-body-sm text-foreground-subtle">
|
||
{pagination.offset + idx + 1}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 sm:px-6 py-4">
|
||
<span className="font-mono text-body-sm sm:text-body font-medium text-foreground">
|
||
.{tld.tld}
|
||
</span>
|
||
{!isAuthenticated && idx === 0 && pagination.offset === 0 && (
|
||
<span className="ml-2 text-xs text-accent">Preview</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell">
|
||
<span className={clsx(
|
||
"text-ui-sm px-2 py-0.5 rounded-full",
|
||
tld.type === 'generic' ? 'text-accent bg-accent-muted' :
|
||
tld.type === 'ccTLD' ? 'text-blue-400 bg-blue-400/10' :
|
||
'text-purple-400 bg-purple-400/10'
|
||
)}>
|
||
{tld.type}
|
||
</span>
|
||
</td>
|
||
<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">
|
||
{showFullData ? (
|
||
<span className="text-body-sm font-medium text-foreground">
|
||
${tld.avg_registration_price.toFixed(2)}
|
||
</span>
|
||
) : (
|
||
<span className="text-body-sm text-foreground-subtle">•••</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 sm:px-6 py-4 text-right hidden sm:table-cell">
|
||
{showFullData ? (
|
||
<span className="text-body-sm text-accent">
|
||
${tld.min_registration_price.toFixed(2)}
|
||
</span>
|
||
) : (
|
||
<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 ? (
|
||
<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
|
||
href={isAuthenticated ? `/tld-pricing/${tld.tld}` : `/login?redirect=/tld-pricing/${tld.tld}`}
|
||
className="flex items-center gap-1 text-ui-sm text-accent hover:text-accent-hover transition-colors opacity-0 group-hover:opacity-100"
|
||
>
|
||
Details
|
||
<ArrowRight className="w-3 h-3" />
|
||
</Link>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{!loading && pagination.total > pagination.limit && (
|
||
<div className="px-4 sm:px-6 py-4 border-t border-border flex flex-col sm:flex-row items-center justify-between gap-4">
|
||
<p className="text-ui-sm text-foreground-subtle">
|
||
Showing {pagination.offset + 1}-{Math.min(pagination.offset + pagination.limit, pagination.total)} of {pagination.total} TLDs
|
||
</p>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{/* Previous Button */}
|
||
<button
|
||
onClick={() => handlePageChange(pagination.offset - pagination.limit)}
|
||
disabled={pagination.offset === 0}
|
||
className={clsx(
|
||
"flex items-center gap-1 px-3 py-2 rounded-lg text-ui-sm transition-all",
|
||
pagination.offset === 0
|
||
? "text-foreground-subtle cursor-not-allowed"
|
||
: "text-foreground hover:bg-background-secondary"
|
||
)}
|
||
>
|
||
<ChevronLeft className="w-4 h-4" />
|
||
Prev
|
||
</button>
|
||
|
||
{/* Page Numbers */}
|
||
<div className="hidden sm:flex items-center gap-1">
|
||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||
let pageNum: number
|
||
if (totalPages <= 5) {
|
||
pageNum = i + 1
|
||
} else if (currentPage <= 3) {
|
||
pageNum = i + 1
|
||
} else if (currentPage >= totalPages - 2) {
|
||
pageNum = totalPages - 4 + i
|
||
} else {
|
||
pageNum = currentPage - 2 + i
|
||
}
|
||
|
||
return (
|
||
<button
|
||
key={pageNum}
|
||
onClick={() => handlePageChange((pageNum - 1) * pagination.limit)}
|
||
className={clsx(
|
||
"w-9 h-9 rounded-lg text-ui-sm font-medium transition-all",
|
||
currentPage === pageNum
|
||
? "bg-accent text-background"
|
||
: "text-foreground-muted hover:bg-background-secondary hover:text-foreground"
|
||
)}
|
||
>
|
||
{pageNum}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Mobile Page Indicator */}
|
||
<span className="sm:hidden text-ui-sm text-foreground-muted">
|
||
Page {currentPage} of {totalPages}
|
||
</span>
|
||
|
||
{/* Next Button */}
|
||
<button
|
||
onClick={() => handlePageChange(pagination.offset + pagination.limit)}
|
||
disabled={!pagination.has_more}
|
||
className={clsx(
|
||
"flex items-center gap-1 px-3 py-2 rounded-lg text-ui-sm transition-all",
|
||
!pagination.has_more
|
||
? "text-foreground-subtle cursor-not-allowed"
|
||
: "text-foreground hover:bg-background-secondary"
|
||
)}
|
||
>
|
||
Next
|
||
<ChevronRight className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Stats */}
|
||
{!loading && (
|
||
<div className="mt-6 flex justify-center">
|
||
<p className="text-ui-sm text-foreground-subtle">
|
||
{searchQuery
|
||
? `Found ${pagination.total} TLDs matching "${searchQuery}"`
|
||
: `${pagination.total} TLDs available • Sorted by ${sortField === 'popularity' ? 'popularity' : sortField === 'tld' ? 'name' : 'price'}`
|
||
}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
<Footer />
|
||
</div>
|
||
)
|
||
}
|