yves.gugger 7c5669a2a2 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
2025-12-10 13:50:21 +01:00

689 lines
30 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}