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...
This commit is contained in:
@ -326,16 +326,31 @@ def get_max_price(tld_data: dict) -> float:
|
|||||||
return max(r["register"] for r in tld_data["registrars"].values())
|
return max(r["register"] for r in tld_data["registrars"].values())
|
||||||
|
|
||||||
|
|
||||||
|
# Top TLDs by popularity (based on actual domain registration volumes)
|
||||||
|
TOP_TLDS_BY_POPULARITY = [
|
||||||
|
"com", "net", "org", "de", "uk", "cn", "ru", "nl", "br", "au",
|
||||||
|
"io", "co", "ai", "app", "dev", "xyz", "online", "site", "tech", "store",
|
||||||
|
"info", "biz", "me", "tv", "cc", "eu", "fr", "it", "es", "pl",
|
||||||
|
"ch", "at", "be", "se", "no", "dk", "fi", "ie", "nz", "in",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview")
|
@router.get("/overview")
|
||||||
async def get_tld_overview(
|
async def get_tld_overview(
|
||||||
db: Database,
|
db: Database,
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(25, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
sort_by: str = Query("popularity", enum=["popularity", "price_asc", "price_desc", "name"]),
|
sort_by: str = Query("popularity", enum=["popularity", "price_asc", "price_desc", "name"]),
|
||||||
|
search: str = Query(None, description="Search TLDs by name"),
|
||||||
source: str = Query("auto", enum=["auto", "db", "static"]),
|
source: str = Query("auto", enum=["auto", "db", "static"]),
|
||||||
):
|
):
|
||||||
"""Get overview of TLDs with current pricing.
|
"""Get overview of TLDs with current pricing, pagination, and search.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
limit: Number of results per page (default 25)
|
||||||
|
offset: Skip N results for pagination
|
||||||
|
search: Filter TLDs by name (e.g., "com", "io")
|
||||||
|
sort_by: Sort order - popularity (default), price_asc, price_desc, name
|
||||||
source: Data source - "auto" (DB first, fallback to static), "db" (only DB), "static" (only static)
|
source: Data source - "auto" (DB first, fallback to static), "db" (only DB), "static" (only static)
|
||||||
"""
|
"""
|
||||||
tld_list = []
|
tld_list = []
|
||||||
@ -359,6 +374,7 @@ async def get_tld_overview(
|
|||||||
"max_registration_price": max(prices),
|
"max_registration_price": max(prices),
|
||||||
"registrar_count": len(data["registrars"]),
|
"registrar_count": len(data["registrars"]),
|
||||||
"trend": TLD_DATA.get(tld, {}).get("trend", "stable"),
|
"trend": TLD_DATA.get(tld, {}).get("trend", "stable"),
|
||||||
|
"popularity_rank": TOP_TLDS_BY_POPULARITY.index(tld) if tld in TOP_TLDS_BY_POPULARITY else 999,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Use static data as fallback or if requested
|
# Use static data as fallback or if requested
|
||||||
@ -374,19 +390,36 @@ async def get_tld_overview(
|
|||||||
"max_registration_price": get_max_price(data),
|
"max_registration_price": get_max_price(data),
|
||||||
"registrar_count": len(data["registrars"]),
|
"registrar_count": len(data["registrars"]),
|
||||||
"trend": data["trend"],
|
"trend": data["trend"],
|
||||||
|
"popularity_rank": TOP_TLDS_BY_POPULARITY.index(tld) if tld in TOP_TLDS_BY_POPULARITY else 999,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Apply search filter
|
||||||
|
if search:
|
||||||
|
search_lower = search.lower().lstrip(".")
|
||||||
|
tld_list = [t for t in tld_list if search_lower in t["tld"].lower()]
|
||||||
|
|
||||||
|
# Store total before pagination
|
||||||
|
total = len(tld_list)
|
||||||
|
|
||||||
# Sort
|
# Sort
|
||||||
if sort_by == "price_asc":
|
if sort_by == "popularity":
|
||||||
|
tld_list.sort(key=lambda x: (x["popularity_rank"], x["tld"]))
|
||||||
|
elif sort_by == "price_asc":
|
||||||
tld_list.sort(key=lambda x: x["avg_registration_price"])
|
tld_list.sort(key=lambda x: x["avg_registration_price"])
|
||||||
elif sort_by == "price_desc":
|
elif sort_by == "price_desc":
|
||||||
tld_list.sort(key=lambda x: x["avg_registration_price"], reverse=True)
|
tld_list.sort(key=lambda x: x["avg_registration_price"], reverse=True)
|
||||||
elif sort_by == "name":
|
elif sort_by == "name":
|
||||||
tld_list.sort(key=lambda x: x["tld"])
|
tld_list.sort(key=lambda x: x["tld"])
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
paginated = tld_list[offset:offset + limit]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tlds": tld_list[:limit],
|
"tlds": paginated,
|
||||||
"total": len(tld_list),
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"has_more": offset + limit < total,
|
||||||
"source": data_source,
|
"source": data_source,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from 'react'
|
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||||
import { Header } from '@/components/Header'
|
import { Header } from '@/components/Header'
|
||||||
import { Footer } from '@/components/Footer'
|
import { Footer } from '@/components/Footer'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
@ -16,6 +16,9 @@ import {
|
|||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Lock,
|
Lock,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronLeft,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@ -29,6 +32,7 @@ interface TldData {
|
|||||||
max_registration_price: number
|
max_registration_price: number
|
||||||
registrar_count: number
|
registrar_count: number
|
||||||
trend: string
|
trend: string
|
||||||
|
popularity_rank?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrendingTld {
|
interface TrendingTld {
|
||||||
@ -38,17 +42,17 @@ interface TrendingTld {
|
|||||||
current_price: number
|
current_price: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TldHistoryData {
|
interface PaginationData {
|
||||||
history: Array<{
|
total: number
|
||||||
date: string
|
limit: number
|
||||||
price: number
|
offset: number
|
||||||
}>
|
has_more: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortField = 'tld' | 'avg_registration_price' | 'min_registration_price' | 'registrar_count'
|
type SortField = 'popularity' | 'tld' | 'avg_registration_price' | 'min_registration_price'
|
||||||
type SortDirection = 'asc' | 'desc'
|
type SortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
// Mini sparkline chart component with real data - Enhanced version
|
// Mini sparkline chart component
|
||||||
function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boolean }) {
|
function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boolean }) {
|
||||||
const [historyData, setHistoryData] = useState<number[]>([])
|
const [historyData, setHistoryData] = useState<number[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -56,6 +60,8 @@ function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boo
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
loadHistory()
|
loadHistory()
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [tld, isAuthenticated])
|
}, [tld, isAuthenticated])
|
||||||
|
|
||||||
@ -64,9 +70,9 @@ function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boo
|
|||||||
const data = await api.getTldHistory(tld, 365)
|
const data = await api.getTldHistory(tld, 365)
|
||||||
const history = data.history || []
|
const history = data.history || []
|
||||||
const sampledData = history
|
const sampledData = history
|
||||||
.filter((_, i) => i % Math.floor(history.length / 12) === 0)
|
.filter((_: unknown, i: number) => i % Math.max(1, Math.floor(history.length / 12)) === 0)
|
||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
.map(h => h.price)
|
.map((h: { price: number }) => h.price)
|
||||||
|
|
||||||
setHistoryData(sampledData.length > 0 ? sampledData : [])
|
setHistoryData(sampledData.length > 0 ? sampledData : [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -77,28 +83,34 @@ function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated || loading || historyData.length === 0) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-ui-sm text-foreground-subtle">
|
<div className="flex items-center gap-2 text-ui-sm text-foreground-subtle">
|
||||||
<Lock className="w-3 h-3" />
|
<Lock className="w-3 h-3" />
|
||||||
Sign in
|
<span>Sign in</span>
|
||||||
</div>
|
</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 min = Math.min(...historyData)
|
||||||
const max = Math.max(...historyData)
|
const max = Math.max(...historyData)
|
||||||
const range = max - min || 1
|
const range = max - min || 1
|
||||||
const isIncreasing = historyData[historyData.length - 1] > historyData[0]
|
const isIncreasing = historyData[historyData.length - 1] > historyData[0]
|
||||||
|
|
||||||
// Create line points
|
|
||||||
const linePoints = historyData.map((value, i) => {
|
const linePoints = historyData.map((value, i) => {
|
||||||
const x = (i / (historyData.length - 1)) * 100
|
const x = (i / (historyData.length - 1)) * 100
|
||||||
const y = 100 - ((value - min) / range) * 80 - 10 // Add padding
|
const y = 100 - ((value - min) / range) * 80 - 10
|
||||||
return `${x},${y}`
|
return `${x},${y}`
|
||||||
}).join(' ')
|
}).join(' ')
|
||||||
|
|
||||||
// Create area path for gradient fill
|
|
||||||
const areaPath = historyData.map((value, i) => {
|
const areaPath = historyData.map((value, i) => {
|
||||||
const x = (i / (historyData.length - 1)) * 100
|
const x = (i / (historyData.length - 1)) * 100
|
||||||
const y = 100 - ((value - min) / range) * 80 - 10
|
const y = 100 - ((value - min) / range) * 80 - 10
|
||||||
@ -111,57 +123,19 @@ function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boo
|
|||||||
<svg className="w-32 h-10" viewBox="0 0 100 100" preserveAspectRatio="none">
|
<svg className="w-32 h-10" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
|
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
<stop
|
<stop offset="0%" stopColor={isIncreasing ? "#f97316" : "#00d4aa"} stopOpacity="0.3" />
|
||||||
offset="0%"
|
<stop offset="100%" stopColor={isIncreasing ? "#f97316" : "#00d4aa"} stopOpacity="0.02" />
|
||||||
stopColor={isIncreasing ? "#f97316" : "#00d4aa"}
|
|
||||||
stopOpacity="0.3"
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
stopColor={isIncreasing ? "#f97316" : "#00d4aa"}
|
|
||||||
stopOpacity="0.02"
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
<path d={areaPath} fill={`url(#${gradientId})`} />
|
||||||
{/* Area fill */}
|
|
||||||
<path
|
|
||||||
d={areaPath}
|
|
||||||
fill={`url(#${gradientId})`}
|
|
||||||
className="transition-all duration-300"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Line */}
|
|
||||||
<polyline
|
<polyline
|
||||||
points={linePoints}
|
points={linePoints}
|
||||||
fill="none"
|
fill="none"
|
||||||
strokeWidth="2.5"
|
strokeWidth="2.5"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className={clsx(
|
className={isIncreasing ? "stroke-[#f97316]" : "stroke-accent"}
|
||||||
"transition-all duration-300",
|
|
||||||
isIncreasing ? "stroke-[#f97316]" : "stroke-accent"
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dots for each data point */}
|
|
||||||
{historyData.map((value, i) => {
|
|
||||||
const x = (i / (historyData.length - 1)) * 100
|
|
||||||
const y = 100 - ((value - min) / range) * 80 - 10
|
|
||||||
return (
|
|
||||||
<circle
|
|
||||||
key={i}
|
|
||||||
cx={x}
|
|
||||||
cy={y}
|
|
||||||
r="1.5"
|
|
||||||
className={clsx(
|
|
||||||
"transition-all duration-300",
|
|
||||||
isIncreasing ? "fill-[#f97316]" : "fill-accent"
|
|
||||||
)}
|
|
||||||
opacity={i === historyData.length - 1 ? "1" : "0.4"}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -175,47 +149,74 @@ function SortIcon({ field, currentField, direction }: { field: SortField, curren
|
|||||||
: <ChevronDown className="w-4 h-4 text-accent" />
|
: <ChevronDown className="w-4 h-4 text-accent" />
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShimmerBlock({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<div className={clsx(
|
|
||||||
"relative overflow-hidden rounded bg-background-tertiary",
|
|
||||||
className
|
|
||||||
)}>
|
|
||||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-foreground/5 to-transparent" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TldPricingPage() {
|
export default function TldPricingPage() {
|
||||||
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
||||||
const [tlds, setTlds] = useState<TldData[]>([])
|
const [tlds, setTlds] = useState<TldData[]>([])
|
||||||
const [trending, setTrending] = useState<TrendingTld[]>([])
|
const [trending, setTrending] = useState<TrendingTld[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [sortField, setSortField] = useState<SortField>('tld')
|
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')
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedSearch(searchQuery)
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [searchQuery])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuth()
|
checkAuth()
|
||||||
loadData()
|
loadTrending()
|
||||||
}, [checkAuth])
|
}, [checkAuth])
|
||||||
|
|
||||||
const loadData = async () => {
|
// Load TLDs with pagination, search, and sort
|
||||||
|
useEffect(() => {
|
||||||
|
loadTlds()
|
||||||
|
}, [debouncedSearch, sortField, sortDirection, pagination.offset])
|
||||||
|
|
||||||
|
const loadTlds = async () => {
|
||||||
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [overviewData, trendingData] = await Promise.all([
|
const sortBy = sortField === 'tld' ? 'name' : sortField === 'popularity' ? 'popularity' :
|
||||||
api.getTldOverview(100),
|
sortField === 'avg_registration_price' ? (sortDirection === 'asc' ? 'price_asc' : 'price_desc') :
|
||||||
api.getTrendingTlds(),
|
(sortDirection === 'asc' ? 'price_asc' : 'price_desc')
|
||||||
])
|
|
||||||
setTlds(overviewData?.tlds || [])
|
const data = await api.getTldOverview(
|
||||||
setTrending(trendingData?.trending || [])
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load TLD data:', error)
|
console.error('Failed to load TLD data:', error)
|
||||||
setTlds([])
|
setTlds([])
|
||||||
setTrending([])
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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) => {
|
const handleSort = (field: SortField) => {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||||
@ -223,22 +224,15 @@ export default function TldPricingPage() {
|
|||||||
setSortField(field)
|
setSortField(field)
|
||||||
setSortDirection('asc')
|
setSortDirection('asc')
|
||||||
}
|
}
|
||||||
|
// Reset to first page on sort change
|
||||||
|
setPagination(prev => ({ ...prev, offset: 0 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedTlds = useMemo(() => {
|
const handlePageChange = (newOffset: number) => {
|
||||||
const sorted = [...tlds].sort((a, b) => {
|
setPagination(prev => ({ ...prev, offset: newOffset }))
|
||||||
let aVal: number | string = a[sortField]
|
// Scroll to top of table
|
||||||
let bVal: number | string = b[sortField]
|
window.scrollTo({ top: 300, behavior: 'smooth' })
|
||||||
|
}
|
||||||
if (typeof aVal === 'string') aVal = aVal.toLowerCase()
|
|
||||||
if (typeof bVal === 'string') bVal = bVal.toLowerCase()
|
|
||||||
|
|
||||||
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1
|
|
||||||
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
return sorted
|
|
||||||
}, [tlds, sortField, sortDirection])
|
|
||||||
|
|
||||||
const getTrendIcon = (trend: string) => {
|
const getTrendIcon = (trend: string) => {
|
||||||
switch (trend) {
|
switch (trend) {
|
||||||
@ -251,7 +245,11 @@ export default function TldPricingPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading || authLoading) {
|
// Pagination calculations
|
||||||
|
const currentPage = Math.floor(pagination.offset / pagination.limit) + 1
|
||||||
|
const totalPages = Math.ceil(pagination.total / pagination.limit)
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<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 className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
@ -280,7 +278,7 @@ export default function TldPricingPage() {
|
|||||||
Domain Extension Pricing
|
Domain Extension Pricing
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-body sm:text-body-lg text-foreground-muted max-w-2xl mx-auto">
|
<p className="text-body sm:text-body-lg text-foreground-muted max-w-2xl mx-auto">
|
||||||
Track price trends across all major TLDs. Compare prices and monitor trends over time.
|
Track price trends across {pagination.total}+ TLDs. Compare prices and monitor trends over time.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -294,7 +292,7 @@ export default function TldPricingPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-body-sm font-medium text-foreground">Unlock Full TLD Data</p>
|
<p className="text-body-sm font-medium text-foreground">Unlock Full TLD Data</p>
|
||||||
<p className="text-ui-sm text-foreground-muted">
|
<p className="text-ui-sm text-foreground-muted">
|
||||||
Sign in to see detailed pricing and trends.
|
Sign in to see detailed pricing, charts, and trends.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -324,7 +322,6 @@ export default function TldPricingPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<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="font-mono text-body-lg sm:text-heading-sm text-foreground">.{item.tld}</span>
|
||||||
{isAuthenticated ? (
|
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"text-ui-sm font-medium px-2 py-0.5 rounded-full",
|
"text-ui-sm font-medium px-2 py-0.5 rounded-full",
|
||||||
item.price_change > 0
|
item.price_change > 0
|
||||||
@ -333,21 +330,14 @@ export default function TldPricingPage() {
|
|||||||
)}>
|
)}>
|
||||||
{item.price_change > 0 ? '+' : ''}{item.price_change.toFixed(1)}%
|
{item.price_change > 0 ? '+' : ''}{item.price_change.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<ShimmerBlock className="h-5 w-14" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-body-sm text-foreground-muted mb-2 line-clamp-2">
|
<p className="text-body-sm text-foreground-muted mb-2 line-clamp-2">
|
||||||
{isAuthenticated ? item.reason : 'Sign in to view trend details'}
|
{item.reason}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{isAuthenticated ? (
|
|
||||||
<span className="text-body-sm text-foreground-subtle">
|
<span className="text-body-sm text-foreground-subtle">
|
||||||
${item.current_price.toFixed(2)}/yr
|
${item.current_price.toFixed(2)}/yr
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<ShimmerBlock className="h-5 w-20" />
|
|
||||||
)}
|
|
||||||
<ChevronRight className="w-4 h-4 text-foreground-subtle group-hover:text-accent transition-colors" />
|
<ChevronRight className="w-4 h-4 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@ -356,12 +346,52 @@ export default function TldPricingPage() {
|
|||||||
</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 */}
|
{/* TLD Table */}
|
||||||
<div className="bg-background-secondary/30 border border-border rounded-xl overflow-hidden animate-slide-up">
|
<div className="bg-background-secondary/30 border border-border rounded-xl overflow-hidden animate-slide-up">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-background-secondary border-b border-border">
|
<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">
|
<th className="text-left px-4 sm:px-6 py-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSort('tld')}
|
onClick={() => handleSort('tld')}
|
||||||
@ -372,14 +402,10 @@ export default function TldPricingPage() {
|
|||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left px-4 sm:px-6 py-4 hidden lg:table-cell">
|
<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">
|
<span className="text-ui-sm text-foreground-subtle font-medium">Type</span>
|
||||||
Description
|
|
||||||
</span>
|
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left px-4 sm:px-6 py-4 hidden md:table-cell">
|
<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">
|
<span className="text-ui-sm text-foreground-subtle font-medium">12-Month Trend</span>
|
||||||
12-Month Trend
|
|
||||||
</span>
|
|
||||||
</th>
|
</th>
|
||||||
<th className="text-right px-4 sm:px-6 py-4">
|
<th className="text-right px-4 sm:px-6 py-4">
|
||||||
<button
|
<button
|
||||||
@ -399,15 +425,6 @@ export default function TldPricingPage() {
|
|||||||
<SortIcon field="min_registration_price" currentField={sortField} direction={sortDirection} />
|
<SortIcon field="min_registration_price" currentField={sortField} direction={sortDirection} />
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
<th className="text-center px-4 sm:px-6 py-4 hidden xl:table-cell">
|
|
||||||
<button
|
|
||||||
onClick={() => handleSort('registrar_count')}
|
|
||||||
className="flex items-center gap-2 mx-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Registrars
|
|
||||||
<SortIcon field="registrar_count" currentField={sortField} direction={sortDirection} />
|
|
||||||
</button>
|
|
||||||
</th>
|
|
||||||
<th className="text-center px-4 sm:px-6 py-4 hidden sm:table-cell">
|
<th className="text-center px-4 sm:px-6 py-4 hidden sm:table-cell">
|
||||||
<span className="text-ui-sm text-foreground-subtle font-medium">Trend</span>
|
<span className="text-ui-sm text-foreground-subtle font-medium">Trend</span>
|
||||||
</th>
|
</th>
|
||||||
@ -415,19 +432,50 @@ export default function TldPricingPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{sortedTlds.map((tld, idx) => (
|
{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
|
<tr
|
||||||
key={tld.tld}
|
key={tld.tld}
|
||||||
className="hover:bg-background-secondary/50 transition-colors group"
|
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">
|
<td className="px-4 sm:px-6 py-4">
|
||||||
<span className="font-mono text-body-sm sm:text-body font-medium text-foreground">
|
<span className="font-mono text-body-sm sm:text-body font-medium text-foreground">
|
||||||
.{tld.tld}
|
.{tld.tld}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell">
|
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell">
|
||||||
<span className="text-body-sm text-foreground-muted line-clamp-1">
|
<span className={clsx(
|
||||||
{tld.description}
|
"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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 sm:px-6 py-4 hidden md:table-cell">
|
<td className="px-4 sm:px-6 py-4 hidden md:table-cell">
|
||||||
@ -451,15 +499,6 @@ export default function TldPricingPage() {
|
|||||||
<span className="text-body-sm text-foreground-subtle">•••</span>
|
<span className="text-body-sm text-foreground-subtle">•••</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 sm:px-6 py-4 text-center hidden xl:table-cell">
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<span className="text-body-sm text-foreground-muted">
|
|
||||||
{tld.registrar_count}
|
|
||||||
</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">
|
<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" />}
|
{isAuthenticated ? getTrendIcon(tld.trend) : <Minus className="w-4 h-4 text-foreground-subtle mx-auto" />}
|
||||||
</td>
|
</td>
|
||||||
@ -473,18 +512,101 @@ export default function TldPricingPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
|
{!loading && (
|
||||||
<div className="mt-6 flex justify-center">
|
<div className="mt-6 flex justify-center">
|
||||||
<p className="text-ui-sm text-foreground-subtle">
|
<p className="text-ui-sm text-foreground-subtle">
|
||||||
Showing {sortedTlds.length} TLDs
|
{searchQuery
|
||||||
|
? `Found ${pagination.total} TLDs matching "${searchQuery}"`
|
||||||
|
: `${pagination.total} TLDs available • Sorted by ${sortField === 'popularity' ? 'popularity' : sortField === 'tld' ? 'name' : 'price'}`
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@ -225,7 +225,20 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TLD Pricing
|
// TLD Pricing
|
||||||
async getTldOverview(limit = 20) {
|
async getTldOverview(
|
||||||
|
limit = 25,
|
||||||
|
offset = 0,
|
||||||
|
sortBy: 'popularity' | 'price_asc' | 'price_desc' | 'name' = 'popularity',
|
||||||
|
search?: string
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: limit.toString(),
|
||||||
|
offset: offset.toString(),
|
||||||
|
sort_by: sortBy,
|
||||||
|
})
|
||||||
|
if (search) {
|
||||||
|
params.append('search', search)
|
||||||
|
}
|
||||||
return this.request<{
|
return this.request<{
|
||||||
tlds: Array<{
|
tlds: Array<{
|
||||||
tld: string
|
tld: string
|
||||||
@ -236,9 +249,14 @@ class ApiClient {
|
|||||||
max_registration_price: number
|
max_registration_price: number
|
||||||
registrar_count: number
|
registrar_count: number
|
||||||
trend: string
|
trend: string
|
||||||
|
popularity_rank?: number
|
||||||
}>
|
}>
|
||||||
total: number
|
total: number
|
||||||
}>(`/tld-prices/overview?limit=${limit}`)
|
limit: number
|
||||||
|
offset: number
|
||||||
|
has_more: boolean
|
||||||
|
source: string
|
||||||
|
}>(`/tld-prices/overview?${params.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTldHistory(tld: string, days = 90) {
|
async getTldHistory(tld: string, days = 90) {
|
||||||
|
|||||||
Reference in New Issue
Block a user