yves.gugger ebcf5f1f16 feat: TLD page - search, pagination, popularity sort
- Add search functionality (filter TLDs by name)
- Add pagination (25 per page with page navigation)
- Sort by popularity (Top TLDs first: com, net, org, de, uk...)
- Show all info for authenticated users
- Backend: offset/limit params, search filter, popularity ranking

TLD order: com > net > org > de > uk > io > ai > app...
2025-12-08 09:46:39 +01:00

617 lines
26 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
registrar_count: number
trend: 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 flex flex-col">
{/* Ambient glow */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
</div>
<Header />
<main className="relative pt-28 sm:pt-32 pb-16 sm:pb-20 px-4 sm:px-6 flex-1">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="text-center mb-12 sm:mb-16 animate-fade-in">
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-background-secondary/80 backdrop-blur-sm border border-border rounded-full mb-6">
<BarChart3 className="w-4 h-4 text-accent" />
<span className="text-ui-sm text-foreground-muted">TLD Price Intelligence</span>
</div>
<h1 className="font-display text-[2rem] sm:text-[2.75rem] md:text-[3.5rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4">
Domain Extension Pricing
</h1>
<p className="text-body sm:text-body-lg text-foreground-muted max-w-2xl mx-auto">
Track price trends across {pagination.total}+ TLDs. Compare prices and monitor trends over time.
</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">Unlock Full TLD Data</p>
<p className="text-ui-sm text-foreground-muted">
Sign in to see 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"
>
Get Started 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" />
Trending 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 md:table-cell">
<span className="text-ui-sm text-foreground-subtle font-medium">12-Month Trend</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-center px-4 sm:px-6 py-4 hidden sm:table-cell">
<span className="text-ui-sm text-foreground-subtle font-medium">Trend</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 md: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 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">
{searchQuery ? `No TLDs found matching "${searchQuery}"` : 'No TLDs found'}
</td>
</tr>
) : (
tlds.map((tld, idx) => (
<tr
key={tld.tld}
className="hover:bg-background-secondary/50 transition-colors group"
>
<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>
</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 md:table-cell">
<MiniChart tld={tld.tld} isAuthenticated={isAuthenticated} />
</td>
<td className="px-4 sm:px-6 py-4 text-right">
{isAuthenticated ? (
<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">
{isAuthenticated ? (
<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-center hidden sm:table-cell">
{isAuthenticated ? getTrendIcon(tld.trend) : <Minus className="w-4 h-4 text-foreground-subtle mx-auto" />}
</td>
<td className="px-4 sm:px-6 py-4">
<Link
href={isAuthenticated ? `/tld-pricing/${tld.tld}` : '/register'}
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>
)
}