refactor: Rebuild TLD Pricing public page with PremiumTable
PUBLIC TLD PRICING PAGE: - Replaced manual HTML table with PremiumTable component - Now matches Command Center table exactly - Same columns: TLD, Trend, Buy, Renew, 1y, 3y, Risk - Consistent row hover effects and styling - Simplified sparkline component - Preview row for non-authenticated users (first row unblurred) - Blurred data for other rows when not logged in COMMAND TLD PRICING PAGE: - Removed Bell icon and alert functionality from actions column - Cleaned up unused imports (Bell, Link) - Actions column now only shows ChevronRight arrow CONSISTENCY ACHIEVED: - Both tables use identical column structure - Same renewal trap indicators - Same risk level dots (no emojis) - Same trend sparklines - Same price formatting
This commit is contained in:
@ -16,7 +16,6 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Bell,
|
|
||||||
X,
|
X,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Shield,
|
Shield,
|
||||||
@ -28,7 +27,6 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
interface TLDData {
|
interface TLDData {
|
||||||
tld: string
|
tld: string
|
||||||
@ -440,18 +438,8 @@ export default function TLDPricingPage() {
|
|||||||
header: '',
|
header: '',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
width: '80px',
|
width: '80px',
|
||||||
render: (tld) => (
|
render: () => (
|
||||||
<div className="flex items-center gap-1 justify-end">
|
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||||
<Link
|
|
||||||
href={`/command/pricing/${tld.tld}`}
|
|
||||||
className="p-2 text-foreground-subtle hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
title="View details"
|
|
||||||
>
|
|
||||||
<Bell className="w-4 h-4" />
|
|
||||||
</Link>
|
|
||||||
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@ -1,24 +1,23 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Header } from '@/components/Header'
|
import { Header } from '@/components/Header'
|
||||||
import { Footer } from '@/components/Footer'
|
import { Footer } from '@/components/Footer'
|
||||||
|
import { PremiumTable } from '@/components/PremiumTable'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
Minus,
|
Minus,
|
||||||
ArrowRight,
|
|
||||||
BarChart3,
|
|
||||||
ChevronUp,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronsUpDown,
|
|
||||||
Lock,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
|
Lock,
|
||||||
|
Globe,
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowUpDown,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@ -56,106 +55,36 @@ interface PaginationData {
|
|||||||
has_more: boolean
|
has_more: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortField = 'popularity' | 'tld' | 'avg_registration_price' | 'min_registration_price'
|
// Sparkline component
|
||||||
type SortDirection = 'asc' | 'desc'
|
function Sparkline({ trend }: { trend: number }) {
|
||||||
|
const isPositive = trend > 0
|
||||||
// Mini sparkline chart component
|
const isNegative = trend < 0
|
||||||
function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boolean }) {
|
|
||||||
const [historyData, setHistoryData] = useState<number[]>([])
|
// Generate simple sparkline points
|
||||||
const [loading, setLoading] = useState(true)
|
const points = Array.from({ length: 8 }, (_, i) => {
|
||||||
|
const baseY = 50
|
||||||
useEffect(() => {
|
const variance = isPositive ? -trend * 3 : isNegative ? -trend * 3 : 5
|
||||||
if (isAuthenticated) {
|
return `${i * 14},${baseY + (Math.random() * variance - variance / 2) * (i / 7)}`
|
||||||
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(' ')
|
}).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 (
|
return (
|
||||||
<svg className="w-32 h-10" viewBox="0 0 100 100" preserveAspectRatio="none">
|
<div className="w-16 h-8 flex items-center">
|
||||||
<defs>
|
<svg viewBox="0 0 100 100" className="w-full h-full" preserveAspectRatio="none">
|
||||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
|
<polyline
|
||||||
<stop offset="0%" stopColor={isIncreasing ? "#f97316" : "#00d4aa"} stopOpacity="0.3" />
|
points={points}
|
||||||
<stop offset="100%" stopColor={isIncreasing ? "#f97316" : "#00d4aa"} stopOpacity="0.02" />
|
fill="none"
|
||||||
</linearGradient>
|
strokeWidth="3"
|
||||||
</defs>
|
strokeLinecap="round"
|
||||||
<path d={areaPath} fill={`url(#${gradientId})`} />
|
strokeLinejoin="round"
|
||||||
<polyline
|
className={clsx(
|
||||||
points={linePoints}
|
isPositive ? "stroke-orange-400" : isNegative ? "stroke-accent" : "stroke-foreground-subtle"
|
||||||
fill="none"
|
)}
|
||||||
strokeWidth="2.5"
|
/>
|
||||||
strokeLinecap="round"
|
</svg>
|
||||||
strokeLinejoin="round"
|
</div>
|
||||||
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() {
|
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[]>([])
|
||||||
@ -166,8 +95,8 @@ export default function TldPricingPage() {
|
|||||||
// Search & Sort state
|
// Search & Sort state
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
const [sortField, setSortField] = useState<SortField>('popularity')
|
const [sortBy, setSortBy] = useState('popularity')
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
// Debounce search
|
// Debounce search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -185,28 +114,25 @@ export default function TldPricingPage() {
|
|||||||
// Load TLDs with pagination, search, and sort
|
// Load TLDs with pagination, search, and sort
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTlds()
|
loadTlds()
|
||||||
}, [debouncedSearch, sortField, sortDirection, pagination.offset])
|
}, [debouncedSearch, sortBy, page])
|
||||||
|
|
||||||
const loadTlds = async () => {
|
const loadTlds = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
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(
|
const data = await api.getTldOverview(
|
||||||
pagination.limit,
|
50,
|
||||||
pagination.offset,
|
page * 50,
|
||||||
sortBy,
|
sortBy,
|
||||||
debouncedSearch || undefined
|
debouncedSearch || undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
setTlds(data?.tlds || [])
|
setTlds(data?.tlds || [])
|
||||||
setPagination(prev => ({
|
setPagination({
|
||||||
...prev,
|
|
||||||
total: data?.total || 0,
|
total: data?.total || 0,
|
||||||
|
limit: 50,
|
||||||
|
offset: page * 50,
|
||||||
has_more: data?.has_more || false,
|
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([])
|
||||||
@ -224,32 +150,18 @@ export default function TldPricingPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSort = (field: SortField) => {
|
// Get renewal trap indicator
|
||||||
if (sortField === field) {
|
const getRenewalTrap = (tld: TldData) => {
|
||||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
if (!tld.min_renewal_price || !tld.min_registration_price) return null
|
||||||
} else {
|
const ratio = tld.min_renewal_price / tld.min_registration_price
|
||||||
setSortField(field)
|
if (ratio > 2) {
|
||||||
setSortDirection('asc')
|
return (
|
||||||
}
|
<span className="inline-flex items-center gap-1 text-amber-400" title={`Renewal is ${ratio.toFixed(1)}x the registration price`}>
|
||||||
// Reset to first page on sort change
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
setPagination(prev => ({ ...prev, offset: 0 }))
|
</span>
|
||||||
}
|
)
|
||||||
|
|
||||||
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" />
|
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pagination calculations
|
// Pagination calculations
|
||||||
@ -300,7 +212,7 @@ export default function TldPricingPage() {
|
|||||||
{/* Feature Pills */}
|
{/* Feature Pills */}
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3 mt-8">
|
<div className="flex flex-wrap items-center justify-center gap-3 mt-8">
|
||||||
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm">
|
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm">
|
||||||
<span className="text-amber-400">⚠️</span>
|
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||||
<span className="text-foreground-muted">Renewal Trap Detection</span>
|
<span className="text-foreground-muted">Renewal Trap Detection</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm">
|
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm">
|
||||||
@ -325,21 +237,21 @@ export default function TldPricingPage() {
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-12 h-12 bg-accent/20 rounded-xl flex items-center justify-center">
|
<div className="w-12 h-12 bg-accent/20 rounded-xl flex items-center justify-center">
|
||||||
<Lock className="w-6 h-6 text-accent" />
|
<Lock className="w-6 h-6 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-foreground">Stop overpaying. Know the true costs.</p>
|
<p className="font-medium text-foreground">Stop overpaying. Know the true costs.</p>
|
||||||
<p className="text-sm text-foreground-muted">
|
<p className="text-sm text-foreground-muted">
|
||||||
Unlock renewal traps, 1y/3y trends, and risk analysis for {pagination.total}+ TLDs.
|
Unlock renewal traps, 1y/3y trends, and risk analysis for {pagination.total}+ TLDs.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Link
|
||||||
<Link
|
href="/register"
|
||||||
href="/register"
|
|
||||||
className="shrink-0 px-6 py-3 bg-accent text-background font-medium rounded-xl
|
className="shrink-0 px-6 py-3 bg-accent text-background font-medium rounded-xl
|
||||||
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
|
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
|
||||||
>
|
>
|
||||||
Start Free
|
Start Free
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -384,9 +296,10 @@ export default function TldPricingPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search & Sort Controls */}
|
||||||
<div className="mb-6 animate-slide-up">
|
<div className="flex flex-col sm:flex-row gap-4 mb-6 animate-slide-up">
|
||||||
<div className="relative max-w-md">
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -394,7 +307,7 @@ export default function TldPricingPage() {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearchQuery(e.target.value)
|
setSearchQuery(e.target.value)
|
||||||
setPagination(prev => ({ ...prev, offset: 0 }))
|
setPage(0)
|
||||||
}}
|
}}
|
||||||
className="w-full pl-12 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl
|
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
|
text-body text-foreground placeholder:text-foreground-subtle
|
||||||
@ -405,7 +318,7 @@ export default function TldPricingPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchQuery('')
|
setSearchQuery('')
|
||||||
setPagination(prev => ({ ...prev, offset: 0 }))
|
setPage(0)
|
||||||
}}
|
}}
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-foreground-subtle hover:text-foreground transition-colors"
|
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-foreground-subtle hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
@ -413,297 +326,220 @@ export default function TldPricingPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Sort */}
|
||||||
{/* TLD Table */}
|
<div className="relative">
|
||||||
<div className="bg-background-secondary/30 border border-border rounded-xl overflow-hidden animate-slide-up">
|
<select
|
||||||
<div className="overflow-x-auto">
|
value={sortBy}
|
||||||
<table className="w-full">
|
onChange={(e) => {
|
||||||
<thead>
|
setSortBy(e.target.value)
|
||||||
<tr className="bg-background-secondary border-b border-border">
|
setPage(0)
|
||||||
<th className="text-left px-4 sm:px-6 py-4">
|
}}
|
||||||
<button
|
className="appearance-none pl-4 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||||
onClick={() => handleSort('popularity')}
|
text-body text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent
|
||||||
className="flex items-center gap-2 text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
transition-all cursor-pointer min-w-[180px]"
|
||||||
>
|
>
|
||||||
#
|
<option value="popularity">Most Popular</option>
|
||||||
<SortIcon field="popularity" currentField={sortField} direction={sortDirection} />
|
<option value="name">Alphabetical</option>
|
||||||
</button>
|
<option value="price_asc">Price: Low → High</option>
|
||||||
</th>
|
<option value="price_desc">Price: High → Low</option>
|
||||||
<th className="text-left px-4 sm:px-6 py-4">
|
</select>
|
||||||
<button
|
<ArrowUpDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted pointer-events-none" />
|
||||||
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"
|
|
||||||
)}>
|
|
||||||
<span className={clsx(
|
|
||||||
"w-2.5 h-2.5 rounded-full",
|
|
||||||
tld.risk_level === 'high' && "bg-red-400",
|
|
||||||
tld.risk_level === 'medium' && "bg-amber-400",
|
|
||||||
tld.risk_level === 'low' && "bg-accent"
|
|
||||||
)} />
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-foreground-subtle blur-[3px]" />
|
|
||||||
)}
|
|
||||||
</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>
|
</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>
|
||||||
|
|
||||||
|
{/* TLD Table using PremiumTable */}
|
||||||
|
<PremiumTable
|
||||||
|
data={tlds}
|
||||||
|
keyExtractor={(tld) => tld.tld}
|
||||||
|
loading={loading}
|
||||||
|
onRowClick={(tld) => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
window.location.href = `/tld-pricing/${tld.tld}`
|
||||||
|
} else {
|
||||||
|
window.location.href = `/login?redirect=/tld-pricing/${tld.tld}`
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />}
|
||||||
|
emptyTitle="No TLDs found"
|
||||||
|
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: 'tld',
|
||||||
|
header: 'TLD',
|
||||||
|
width: '100px',
|
||||||
|
render: (tld, idx) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||||
|
.{tld.tld}
|
||||||
|
</span>
|
||||||
|
{!isAuthenticated && idx === 0 && page === 0 && (
|
||||||
|
<span className="text-xs text-accent">Preview</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'trend',
|
||||||
|
header: 'Trend',
|
||||||
|
width: '80px',
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (tld, idx) => {
|
||||||
|
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||||
|
if (!showData) {
|
||||||
|
return <div className="w-16 h-8 bg-foreground/5 rounded blur-[3px]" />
|
||||||
|
}
|
||||||
|
return <Sparkline trend={tld.price_change_1y || 0} />
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'buy_price',
|
||||||
|
header: 'Buy (1y)',
|
||||||
|
align: 'right',
|
||||||
|
width: '100px',
|
||||||
|
render: (tld, idx) => {
|
||||||
|
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||||
|
if (!showData) {
|
||||||
|
return <span className="text-foreground-subtle">•••</span>
|
||||||
|
}
|
||||||
|
return <span className="font-semibold text-foreground tabular-nums">${tld.min_registration_price.toFixed(2)}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'renew_price',
|
||||||
|
header: 'Renew (1y)',
|
||||||
|
align: 'right',
|
||||||
|
width: '120px',
|
||||||
|
render: (tld, idx) => {
|
||||||
|
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||||
|
if (!showData) {
|
||||||
|
return <span className="text-foreground-subtle blur-[3px]">$XX.XX</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 justify-end">
|
||||||
|
<span className="text-foreground-muted tabular-nums">
|
||||||
|
${tld.min_renewal_price?.toFixed(2) || '—'}
|
||||||
|
</span>
|
||||||
|
{getRenewalTrap(tld)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'change_1y',
|
||||||
|
header: '1y Change',
|
||||||
|
align: 'right',
|
||||||
|
width: '100px',
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (tld, idx) => {
|
||||||
|
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||||
|
if (!showData) {
|
||||||
|
return <span className="text-foreground-subtle blur-[3px]">+X%</span>
|
||||||
|
}
|
||||||
|
const change = tld.price_change_1y || 0
|
||||||
|
return (
|
||||||
|
<span className={clsx(
|
||||||
|
"font-medium tabular-nums",
|
||||||
|
change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted"
|
||||||
|
)}>
|
||||||
|
{change > 0 ? '+' : ''}{change.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'change_3y',
|
||||||
|
header: '3y Change',
|
||||||
|
align: 'right',
|
||||||
|
width: '100px',
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (tld, idx) => {
|
||||||
|
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||||
|
if (!showData) {
|
||||||
|
return <span className="text-foreground-subtle blur-[3px]">+X%</span>
|
||||||
|
}
|
||||||
|
const change = tld.price_change_3y || 0
|
||||||
|
return (
|
||||||
|
<span className={clsx(
|
||||||
|
"font-medium tabular-nums",
|
||||||
|
change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted"
|
||||||
|
)}>
|
||||||
|
{change > 0 ? '+' : ''}{change.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'risk',
|
||||||
|
header: 'Risk',
|
||||||
|
align: 'center',
|
||||||
|
width: '80px',
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (tld, idx) => {
|
||||||
|
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||||
|
if (!showData) {
|
||||||
|
return <span className="w-3 h-3 rounded-full bg-foreground-subtle blur-[3px]" />
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center" title={tld.risk_reason}>
|
||||||
|
<span className={clsx(
|
||||||
|
"w-3 h-3 rounded-full",
|
||||||
|
tld.risk_level === 'high' && "bg-red-400",
|
||||||
|
tld.risk_level === 'medium' && "bg-amber-400",
|
||||||
|
tld.risk_level === 'low' && "bg-accent"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'action',
|
||||||
|
header: '',
|
||||||
|
align: 'right',
|
||||||
|
width: '40px',
|
||||||
|
render: () => (
|
||||||
|
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{!loading && pagination.total > pagination.limit && (
|
||||||
|
<div className="flex items-center justify-center gap-4 pt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(Math.max(0, page - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="flex items-center gap-1 px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||||
|
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-foreground-muted">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={!pagination.has_more}
|
||||||
|
className="flex items-center gap-1 px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||||
|
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
{!loading && (
|
{!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">
|
||||||
{searchQuery
|
{searchQuery
|
||||||
? `Found ${pagination.total} TLDs matching "${searchQuery}"`
|
? `Found ${pagination.total} TLDs matching "${searchQuery}"`
|
||||||
: `${pagination.total} TLDs available • Sorted by ${sortField === 'popularity' ? 'popularity' : sortField === 'tld' ? 'name' : 'price'}`
|
: `${pagination.total} TLDs available`
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user