feat: Complete TLD Pricing feature across Public → Command → Admin
PUBLIC PAGE (/tld-pricing): - Added renewal price column with trap warning (⚠️ when >2x) - Added 1y trend % column with color coding - Added risk level badges (🟢🟡🔴) - Blur effect for non-authenticated users on premium columns - All data from real backend API (not simulated) PUBLIC DETAIL PAGE (/tld-pricing/[tld]): - Already existed with full features - Shows price history chart, registrar comparison, domain check COMMAND CENTER (/command/pricing): - Full access to all data without blur - Category tabs: All, Tech, Geo, Budget, Premium - Sparklines for trend visualization - Risk-based sorting option COMMAND CENTER DETAIL (/command/pricing/[tld]): - NEW: Professional detail page with CommandCenterLayout - Price history chart with period selection (1M, 3M, 1Y, ALL) - Renewal trap warning banner - Registrar comparison table with trap indicators - Quick domain availability check - TLD info grid (type, registry, introduced, registrars) - Price alert toggle CLEANUP: - /intelligence → redirects to /tld-pricing (backwards compat) - Removed duplicate code All TLD Pricing data now flows from backend with: - Real renewal prices from registrar data - Calculated 1y/3y trends per TLD - Risk level and reason from backend
This commit is contained in:
678
frontend/src/app/command/pricing/[tld]/page.tsx
Normal file
678
frontend/src/app/command/pricing/[tld]/page.tsx
Normal file
@ -0,0 +1,678 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { PageContainer, StatCard } from '@/components/PremiumTable'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
ArrowLeft,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
Calendar,
|
||||
Globe,
|
||||
Building,
|
||||
ExternalLink,
|
||||
Bell,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
Check,
|
||||
X,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Shield,
|
||||
Zap,
|
||||
AlertTriangle,
|
||||
DollarSign,
|
||||
BarChart3,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface TldDetails {
|
||||
tld: string
|
||||
type: string
|
||||
description: string
|
||||
registry: string
|
||||
introduced: number
|
||||
trend: string
|
||||
trend_reason: string
|
||||
pricing: {
|
||||
avg: number
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
registrars: Array<{
|
||||
name: string
|
||||
registration_price: number
|
||||
renewal_price: number
|
||||
transfer_price: number
|
||||
}>
|
||||
cheapest_registrar: string
|
||||
}
|
||||
|
||||
interface TldHistory {
|
||||
tld: string
|
||||
current_price: number
|
||||
price_change_7d: number
|
||||
price_change_30d: number
|
||||
price_change_90d: number
|
||||
trend: string
|
||||
trend_reason: string
|
||||
history: Array<{
|
||||
date: string
|
||||
price: number
|
||||
}>
|
||||
}
|
||||
|
||||
interface DomainCheckResult {
|
||||
domain: string
|
||||
is_available: boolean
|
||||
status: string
|
||||
registrar?: string | null
|
||||
creation_date?: string | null
|
||||
expiration_date?: string | null
|
||||
}
|
||||
|
||||
// Registrar URLs
|
||||
const REGISTRAR_URLS: Record<string, string> = {
|
||||
'Namecheap': 'https://www.namecheap.com/domains/registration/results/?domain=',
|
||||
'Porkbun': 'https://porkbun.com/checkout/search?q=',
|
||||
'Cloudflare': 'https://www.cloudflare.com/products/registrar/',
|
||||
'Google Domains': 'https://domains.google.com/registrar/search?searchTerm=',
|
||||
'GoDaddy': 'https://www.godaddy.com/domainsearch/find?domainToCheck=',
|
||||
'porkbun': 'https://porkbun.com/checkout/search?q=',
|
||||
'Dynadot': 'https://www.dynadot.com/domain/search?domain=',
|
||||
}
|
||||
|
||||
type ChartPeriod = '1M' | '3M' | '1Y' | 'ALL'
|
||||
|
||||
// Premium Chart Component
|
||||
function PriceChart({
|
||||
data,
|
||||
chartStats,
|
||||
}: {
|
||||
data: Array<{ date: string; price: number }>
|
||||
chartStats: { high: number; low: number; avg: number }
|
||||
}) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="h-48 flex items-center justify-center text-foreground-muted">
|
||||
No price history available
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const minPrice = Math.min(...data.map(d => d.price))
|
||||
const maxPrice = Math.max(...data.map(d => d.price))
|
||||
const priceRange = maxPrice - minPrice || 1
|
||||
|
||||
const points = data.map((d, i) => ({
|
||||
x: (i / (data.length - 1)) * 100,
|
||||
y: 100 - ((d.price - minPrice) / priceRange) * 80 - 10,
|
||||
...d,
|
||||
}))
|
||||
|
||||
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
||||
const areaPath = linePath + ` L${points[points.length - 1].x},100 L${points[0].x},100 Z`
|
||||
|
||||
const isRising = data[data.length - 1].price > data[0].price
|
||||
const strokeColor = isRising ? '#f97316' : '#00d4aa'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-48"
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
>
|
||||
<svg
|
||||
className="w-full h-full"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
onMouseMove={(e) => {
|
||||
if (!containerRef.current) return
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const idx = Math.round((x / 100) * (points.length - 1))
|
||||
setHoveredIndex(Math.max(0, Math.min(idx, points.length - 1)))
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={areaPath} fill="url(#chartGradient)" />
|
||||
<path d={linePath} fill="none" stroke={strokeColor} strokeWidth="2" />
|
||||
|
||||
{hoveredIndex !== null && points[hoveredIndex] && (
|
||||
<circle
|
||||
cx={points[hoveredIndex].x}
|
||||
cy={points[hoveredIndex].y}
|
||||
r="4"
|
||||
fill={strokeColor}
|
||||
stroke="#0a0a0a"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Tooltip */}
|
||||
{hoveredIndex !== null && points[hoveredIndex] && (
|
||||
<div
|
||||
className="absolute -top-2 transform -translate-x-1/2 bg-background border border-border rounded-lg px-3 py-2 shadow-lg z-10 pointer-events-none"
|
||||
style={{ left: `${points[hoveredIndex].x}%` }}
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground">${points[hoveredIndex].price.toFixed(2)}</p>
|
||||
<p className="text-xs text-foreground-muted">{new Date(points[hoveredIndex].date).toLocaleDateString()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
<div className="absolute left-0 top-0 bottom-0 flex flex-col justify-between text-xs text-foreground-subtle -ml-12 w-10 text-right">
|
||||
<span>${maxPrice.toFixed(2)}</span>
|
||||
<span>${((maxPrice + minPrice) / 2).toFixed(2)}</span>
|
||||
<span>${minPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CommandTldDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { subscription, fetchSubscription } = useStore()
|
||||
const tld = params.tld as string
|
||||
|
||||
const [details, setDetails] = useState<TldDetails | null>(null)
|
||||
const [history, setHistory] = useState<TldHistory | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [chartPeriod, setChartPeriod] = useState<ChartPeriod>('1Y')
|
||||
const [domainSearch, setDomainSearch] = useState('')
|
||||
const [checkingDomain, setCheckingDomain] = useState(false)
|
||||
const [domainResult, setDomainResult] = useState<DomainCheckResult | null>(null)
|
||||
const [alertEnabled, setAlertEnabled] = useState(false)
|
||||
const [alertLoading, setAlertLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubscription()
|
||||
if (tld) {
|
||||
loadData()
|
||||
loadAlertStatus()
|
||||
}
|
||||
}, [tld, fetchSubscription])
|
||||
|
||||
const loadAlertStatus = async () => {
|
||||
try {
|
||||
const status = await api.getPriceAlertStatus(tld)
|
||||
setAlertEnabled(status.has_alert && status.is_active)
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleAlert = async () => {
|
||||
setAlertLoading(true)
|
||||
try {
|
||||
const result = await api.togglePriceAlert(tld)
|
||||
setAlertEnabled(result.is_active)
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle alert:', err)
|
||||
} finally {
|
||||
setAlertLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [historyData, compareData] = await Promise.all([
|
||||
api.getTldHistory(tld, 365),
|
||||
api.getTldCompare(tld),
|
||||
])
|
||||
|
||||
if (historyData && compareData) {
|
||||
const sortedRegistrars = [...(compareData.registrars || [])].sort((a, b) =>
|
||||
a.registration_price - b.registration_price
|
||||
)
|
||||
|
||||
setDetails({
|
||||
tld: compareData.tld || tld,
|
||||
type: compareData.type || 'generic',
|
||||
description: compareData.description || `Domain extension .${tld}`,
|
||||
registry: compareData.registry || 'Various',
|
||||
introduced: compareData.introduced || 0,
|
||||
trend: historyData.trend || 'stable',
|
||||
trend_reason: historyData.trend_reason || 'Price tracking available',
|
||||
pricing: {
|
||||
avg: compareData.price_range?.avg || historyData.current_price || 0,
|
||||
min: compareData.price_range?.min || historyData.current_price || 0,
|
||||
max: compareData.price_range?.max || historyData.current_price || 0,
|
||||
},
|
||||
registrars: sortedRegistrars,
|
||||
cheapest_registrar: compareData.cheapest_registrar || sortedRegistrars[0]?.name || 'N/A',
|
||||
})
|
||||
setHistory(historyData)
|
||||
} else {
|
||||
setError('Failed to load TLD data')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading TLD data:', err)
|
||||
setError('Failed to load TLD data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredHistory = useMemo(() => {
|
||||
if (!history?.history) return []
|
||||
|
||||
const now = new Date()
|
||||
let cutoffDays = 365
|
||||
|
||||
switch (chartPeriod) {
|
||||
case '1M': cutoffDays = 30; break
|
||||
case '3M': cutoffDays = 90; break
|
||||
case '1Y': cutoffDays = 365; break
|
||||
case 'ALL': cutoffDays = 9999; break
|
||||
}
|
||||
|
||||
const cutoff = new Date(now.getTime() - cutoffDays * 24 * 60 * 60 * 1000)
|
||||
return history.history.filter(h => new Date(h.date) >= cutoff)
|
||||
}, [history, chartPeriod])
|
||||
|
||||
const chartStats = useMemo(() => {
|
||||
if (filteredHistory.length === 0) return { high: 0, low: 0, avg: 0 }
|
||||
const prices = filteredHistory.map(h => h.price)
|
||||
return {
|
||||
high: Math.max(...prices),
|
||||
low: Math.min(...prices),
|
||||
avg: prices.reduce((a, b) => a + b, 0) / prices.length,
|
||||
}
|
||||
}, [filteredHistory])
|
||||
|
||||
const handleDomainCheck = async () => {
|
||||
if (!domainSearch.trim()) return
|
||||
|
||||
setCheckingDomain(true)
|
||||
setDomainResult(null)
|
||||
|
||||
try {
|
||||
const domain = domainSearch.includes('.') ? domainSearch : `${domainSearch}.${tld}`
|
||||
const result = await api.checkDomain(domain, false)
|
||||
setDomainResult({
|
||||
domain,
|
||||
is_available: result.is_available,
|
||||
status: result.status,
|
||||
registrar: result.registrar,
|
||||
creation_date: result.creation_date,
|
||||
expiration_date: result.expiration_date,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Domain check failed:', err)
|
||||
} finally {
|
||||
setCheckingDomain(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getRegistrarUrl = (registrarName: string, domain?: string) => {
|
||||
const baseUrl = REGISTRAR_URLS[registrarName]
|
||||
if (!baseUrl) return '#'
|
||||
if (domain) return `${baseUrl}${domain}`
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
// Calculate renewal trap info
|
||||
const getRenewalInfo = () => {
|
||||
if (!details?.registrars?.length) return null
|
||||
const cheapest = details.registrars[0]
|
||||
const ratio = cheapest.renewal_price / cheapest.registration_price
|
||||
return {
|
||||
registration: cheapest.registration_price,
|
||||
renewal: cheapest.renewal_price,
|
||||
ratio,
|
||||
isTrap: ratio > 2,
|
||||
}
|
||||
}
|
||||
|
||||
const renewalInfo = getRenewalInfo()
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'up': return <TrendingUp className="w-4 h-4" />
|
||||
case 'down': return <TrendingDown className="w-4 h-4" />
|
||||
default: return <Minus className="w-4 h-4" />
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<CommandCenterLayout title={`.${tld}`} subtitle="Loading...">
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<RefreshCw className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !details) {
|
||||
return (
|
||||
<CommandCenterLayout title="TLD Not Found" subtitle="Error loading data">
|
||||
<PageContainer>
|
||||
<div className="text-center py-20">
|
||||
<div className="w-16 h-16 bg-background-secondary rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<X className="w-8 h-8 text-foreground-subtle" />
|
||||
</div>
|
||||
<h1 className="text-xl font-medium text-foreground mb-2">TLD Not Found</h1>
|
||||
<p className="text-foreground-muted mb-8">{error || `The TLD .${tld} could not be found.`}</p>
|
||||
<Link
|
||||
href="/command/pricing"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-all"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to TLD Pricing
|
||||
</Link>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title={`.${details.tld}`}
|
||||
subtitle={details.description}
|
||||
actions={
|
||||
<button
|
||||
onClick={handleToggleAlert}
|
||||
disabled={alertLoading}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all",
|
||||
alertEnabled
|
||||
? "bg-accent/10 text-accent border border-accent/30"
|
||||
: "bg-foreground/5 text-foreground-muted hover:text-foreground hover:bg-foreground/10"
|
||||
)}
|
||||
>
|
||||
<Bell className={clsx("w-4 h-4", alertEnabled && "fill-current")} />
|
||||
{alertEnabled ? 'Alert On' : 'Set Alert'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<PageContainer>
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm mb-6">
|
||||
<Link href="/command/pricing" className="text-foreground-subtle hover:text-foreground transition-colors">
|
||||
TLD Pricing
|
||||
</Link>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-foreground-subtle" />
|
||||
<span className="text-foreground font-medium">.{details.tld}</span>
|
||||
</nav>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="Best Price"
|
||||
value={`$${details.pricing.min.toFixed(2)}`}
|
||||
subtitle={`at ${details.cheapest_registrar}`}
|
||||
icon={DollarSign}
|
||||
accent
|
||||
/>
|
||||
<StatCard
|
||||
title="Renewal"
|
||||
value={renewalInfo ? `$${renewalInfo.renewal.toFixed(2)}` : '—'}
|
||||
subtitle={renewalInfo?.isTrap ? `⚠️ ${renewalInfo.ratio.toFixed(1)}x registration` : 'per year'}
|
||||
icon={RefreshCw}
|
||||
/>
|
||||
<StatCard
|
||||
title="7d Change"
|
||||
value={history ? `${history.price_change_7d > 0 ? '+' : ''}${history.price_change_7d.toFixed(1)}%` : '—'}
|
||||
icon={BarChart3}
|
||||
/>
|
||||
<StatCard
|
||||
title="Trend"
|
||||
value={details.trend === 'up' ? 'Rising' : details.trend === 'down' ? 'Falling' : 'Stable'}
|
||||
subtitle={details.trend_reason}
|
||||
icon={details.trend === 'up' ? TrendingUp : details.trend === 'down' ? TrendingDown : Minus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Renewal Trap Warning */}
|
||||
{renewalInfo?.isTrap && (
|
||||
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-400">Renewal Trap Detected</p>
|
||||
<p className="text-sm text-foreground-muted mt-1">
|
||||
The renewal price (${renewalInfo.renewal.toFixed(2)}) is {renewalInfo.ratio.toFixed(1)}x higher than the registration price (${renewalInfo.registration.toFixed(2)}).
|
||||
Consider the total cost of ownership before registering.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price Chart */}
|
||||
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-medium text-foreground">Price History</h2>
|
||||
<div className="flex items-center gap-1 bg-foreground/5 rounded-lg p-1">
|
||||
{(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map((period) => (
|
||||
<button
|
||||
key={period}
|
||||
onClick={() => setChartPeriod(period)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 text-xs font-medium rounded-md transition-all",
|
||||
chartPeriod === period
|
||||
? "bg-accent text-background"
|
||||
: "text-foreground-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{period}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pl-14">
|
||||
<PriceChart data={filteredHistory} chartStats={chartStats} />
|
||||
</div>
|
||||
|
||||
{/* Chart Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mt-6 pt-6 border-t border-border/30">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-foreground-subtle uppercase mb-1">Period High</p>
|
||||
<p className="text-lg font-medium text-foreground tabular-nums">${chartStats.high.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-foreground-subtle uppercase mb-1">Average</p>
|
||||
<p className="text-lg font-medium text-foreground tabular-nums">${chartStats.avg.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-foreground-subtle uppercase mb-1">Period Low</p>
|
||||
<p className="text-lg font-medium text-foreground tabular-nums">${chartStats.low.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registrar Comparison */}
|
||||
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
|
||||
<h2 className="text-lg font-medium text-foreground mb-6">Registrar Comparison</h2>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/30">
|
||||
<th className="text-left pb-3 text-sm font-medium text-foreground-muted">Registrar</th>
|
||||
<th className="text-right pb-3 text-sm font-medium text-foreground-muted">Register</th>
|
||||
<th className="text-right pb-3 text-sm font-medium text-foreground-muted">Renew</th>
|
||||
<th className="text-right pb-3 text-sm font-medium text-foreground-muted">Transfer</th>
|
||||
<th className="text-right pb-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/20">
|
||||
{details.registrars.map((registrar, idx) => (
|
||||
<tr key={registrar.name} className={clsx(idx === 0 && "bg-accent/5")}>
|
||||
<td className="py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">{registrar.name}</span>
|
||||
{idx === 0 && (
|
||||
<span className="px-2 py-0.5 text-xs bg-accent/10 text-accent rounded-full">Cheapest</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 text-right">
|
||||
<span className={clsx(
|
||||
"font-medium tabular-nums",
|
||||
idx === 0 ? "text-accent" : "text-foreground"
|
||||
)}>
|
||||
${registrar.registration_price.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 text-right">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<span className="text-foreground-muted tabular-nums">${registrar.renewal_price.toFixed(2)}</span>
|
||||
{registrar.renewal_price / registrar.registration_price > 2 && (
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 text-right">
|
||||
<span className="text-foreground-muted tabular-nums">${registrar.transfer_price.toFixed(2)}</span>
|
||||
</td>
|
||||
<td className="py-4 text-right">
|
||||
<a
|
||||
href={getRegistrarUrl(registrar.name)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-accent hover:text-accent/80 transition-colors"
|
||||
>
|
||||
Visit
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Domain Check */}
|
||||
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
|
||||
<h2 className="text-lg font-medium text-foreground mb-4">Quick Domain Check</h2>
|
||||
<p className="text-sm text-foreground-muted mb-4">
|
||||
Check if a domain is available with .{tld}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
value={domainSearch}
|
||||
onChange={(e) => setDomainSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleDomainCheck()}
|
||||
placeholder={`example or example.${tld}`}
|
||||
className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl
|
||||
text-sm text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-accent/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDomainCheck}
|
||||
disabled={checkingDomain || !domainSearch.trim()}
|
||||
className="h-11 px-6 bg-accent text-background font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all disabled:opacity-50"
|
||||
>
|
||||
{checkingDomain ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Check'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{domainResult && (
|
||||
<div className={clsx(
|
||||
"mt-4 p-4 rounded-xl border",
|
||||
domainResult.is_available
|
||||
? "bg-accent/10 border-accent/30"
|
||||
: "bg-foreground/5 border-border/50"
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
{domainResult.is_available ? (
|
||||
<Check className="w-5 h-5 text-accent" />
|
||||
) : (
|
||||
<X className="w-5 h-5 text-foreground-subtle" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{domainResult.domain}</p>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
{domainResult.is_available ? 'Available for registration!' : 'Already registered'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{domainResult.is_available && (
|
||||
<a
|
||||
href={getRegistrarUrl(details.cheapest_registrar, domainResult.domain)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Register at {details.cheapest_registrar}
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TLD Info */}
|
||||
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
|
||||
<h2 className="text-lg font-medium text-foreground mb-4">TLD Information</h2>
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-background/50 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-muted mb-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
<span className="text-xs uppercase">Type</span>
|
||||
</div>
|
||||
<p className="font-medium text-foreground capitalize">{details.type}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background/50 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-muted mb-2">
|
||||
<Building className="w-4 h-4" />
|
||||
<span className="text-xs uppercase">Registry</span>
|
||||
</div>
|
||||
<p className="font-medium text-foreground">{details.registry}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background/50 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-muted mb-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span className="text-xs uppercase">Introduced</span>
|
||||
</div>
|
||||
<p className="font-medium text-foreground">{details.introduced || 'Unknown'}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background/50 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-muted mb-2">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span className="text-xs uppercase">Registrars</span>
|
||||
</div>
|
||||
<p className="font-medium text-foreground">{details.registrars.length} tracked</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
549
frontend/src/app/intelligence/page.tsx
Executable file → Normal file
549
frontend/src/app/intelligence/page.tsx
Executable file → Normal file
@ -1,543 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { PremiumTable, Badge, StatCard } from '@/components/PremiumTable'
|
||||
import {
|
||||
Search,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
ArrowUpDown,
|
||||
DollarSign,
|
||||
BarChart3,
|
||||
RefreshCw,
|
||||
X,
|
||||
ArrowRight,
|
||||
Lock,
|
||||
Sparkles,
|
||||
AlertTriangle,
|
||||
Cpu,
|
||||
MapPin,
|
||||
Coins,
|
||||
Crown,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface TLDData {
|
||||
tld: string
|
||||
min_price: number
|
||||
avg_price: number
|
||||
max_price: number
|
||||
min_renewal_price: number
|
||||
avg_renewal_price: number
|
||||
cheapest_registrar?: string
|
||||
cheapest_registrar_url?: string
|
||||
price_change_7d: number
|
||||
price_change_1y: number
|
||||
price_change_3y: number
|
||||
risk_level: 'low' | 'medium' | 'high'
|
||||
risk_reason: string
|
||||
popularity_rank?: number
|
||||
type?: string
|
||||
}
|
||||
/**
|
||||
* Redirect /intelligence to /tld-pricing
|
||||
* This page is kept for backwards compatibility
|
||||
*/
|
||||
export default function IntelligenceRedirect() {
|
||||
const router = useRouter()
|
||||
|
||||
// Category definitions
|
||||
const CATEGORIES = {
|
||||
all: { label: 'All', icon: Globe, filter: () => true },
|
||||
tech: { label: 'Tech', icon: Cpu, filter: (tld: TLDData) => ['ai', 'io', 'app', 'dev', 'tech', 'code', 'cloud', 'data', 'api', 'software'].includes(tld.tld) },
|
||||
geo: { label: 'Geo', icon: MapPin, filter: (tld: TLDData) => ['ch', 'de', 'uk', 'us', 'fr', 'it', 'es', 'nl', 'at', 'eu', 'co', 'ca', 'au', 'nz', 'jp', 'cn', 'in', 'br', 'mx', 'nyc', 'london', 'paris', 'berlin', 'tokyo', 'swiss'].includes(tld.tld) },
|
||||
budget: { label: 'Budget', icon: Coins, filter: (tld: TLDData) => tld.min_price < 5 },
|
||||
premium: { label: 'Premium', icon: Crown, filter: (tld: TLDData) => tld.min_price >= 50 },
|
||||
}
|
||||
|
||||
type CategoryKey = keyof typeof CATEGORIES
|
||||
|
||||
// Risk level now comes from backend
|
||||
function getRiskInfo(tld: TLDData): { level: 'low' | 'medium' | 'high', reason: string } {
|
||||
return {
|
||||
level: tld.risk_level || 'low',
|
||||
reason: tld.risk_reason || 'Stable'
|
||||
}
|
||||
}
|
||||
|
||||
// Sparkline component
|
||||
function Sparkline({ trend, className }: { trend: number, className?: string }) {
|
||||
const isPositive = trend > 0
|
||||
const isNeutral = trend === 0
|
||||
useEffect(() => {
|
||||
router.replace('/tld-pricing')
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className={clsx("flex items-center gap-1", className)}>
|
||||
<svg width="40" height="16" viewBox="0 0 40 16" className="overflow-visible">
|
||||
{isNeutral ? (
|
||||
<line x1="0" y1="8" x2="40" y2="8" stroke="currentColor" className="text-foreground-muted" strokeWidth="1.5" />
|
||||
) : isPositive ? (
|
||||
<polyline
|
||||
points="0,14 10,12 20,10 30,6 40,2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-orange-400"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
) : (
|
||||
<polyline
|
||||
points="0,2 10,4 20,8 30,12 40,14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-accent"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-foreground-muted">Redirecting to TLD Pricing...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TLDPricingPublicPage() {
|
||||
const { isAuthenticated, checkAuth } = useStore()
|
||||
|
||||
const [tldData, setTldData] = useState<TLDData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<'popularity' | 'price_asc' | 'price_desc' | 'change'>('popularity')
|
||||
const [category, setCategory] = useState<CategoryKey>('all')
|
||||
const [page, setPage] = useState(0)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
// Number of visible rows for non-authenticated users before blur
|
||||
const FREE_VISIBLE_ROWS = 5
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
loadTLDData()
|
||||
}, [page, sortBy])
|
||||
|
||||
const loadTLDData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await api.getTldOverview(
|
||||
50,
|
||||
page * 50,
|
||||
sortBy,
|
||||
)
|
||||
// Map API response to component interface
|
||||
const mapped: TLDData[] = (response.tlds || []).map((tld) => ({
|
||||
tld: tld.tld,
|
||||
min_price: tld.min_registration_price,
|
||||
avg_price: tld.avg_registration_price,
|
||||
max_price: tld.max_registration_price,
|
||||
min_renewal_price: tld.min_renewal_price,
|
||||
avg_renewal_price: tld.avg_renewal_price,
|
||||
price_change_7d: tld.price_change_7d,
|
||||
price_change_1y: tld.price_change_1y,
|
||||
price_change_3y: tld.price_change_3y,
|
||||
risk_level: tld.risk_level,
|
||||
risk_reason: tld.risk_reason,
|
||||
popularity_rank: tld.popularity_rank,
|
||||
type: tld.type,
|
||||
}))
|
||||
setTldData(mapped)
|
||||
setTotal(response.total || 0)
|
||||
} catch (error) {
|
||||
console.error('Failed to load TLD data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
await loadTLDData()
|
||||
setRefreshing(false)
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
const filteredData = tldData
|
||||
.filter(tld => CATEGORIES[category].filter(tld))
|
||||
.filter(tld => tld.tld.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
|
||||
// Calculate stats
|
||||
const lowestPrice = tldData.length > 0
|
||||
? tldData.reduce((min, tld) => Math.min(min, tld.min_price), Infinity)
|
||||
: 0.99
|
||||
const hottestTld = tldData.find(tld => (tld.price_change_7d || 0) > 5)?.tld || 'ai'
|
||||
const trapCount = tldData.filter(tld => tld.risk_level === 'high' || tld.risk_level === 'medium').length
|
||||
|
||||
const getRiskBadge = (tld: TLDData, blurred: boolean) => {
|
||||
const { level, reason } = getRiskInfo(tld)
|
||||
if (blurred) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-foreground/10 blur-[3px] select-none">
|
||||
🟢 Hidden
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
level === 'high' && "bg-red-500/10 text-red-400",
|
||||
level === 'medium' && "bg-amber-500/10 text-amber-400",
|
||||
level === 'low' && "bg-accent/10 text-accent"
|
||||
)}>
|
||||
{level === 'high' && '🔴'}
|
||||
{level === 'medium' && '🟡'}
|
||||
{level === 'low' && '🟢'}
|
||||
<span className="hidden sm:inline ml-1">{reason}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const getRenewalTrap = (tld: TLDData) => {
|
||||
const ratio = tld.min_renewal_price / tld.min_price
|
||||
if (ratio > 2) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-amber-400" title={`Renewal is ${ratio.toFixed(1)}x the registration price`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen bg-background pt-20 sm:pt-24">
|
||||
{/* Hero Header */}
|
||||
<section className="relative py-12 sm:py-20 overflow-hidden">
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(16,185,129,0.05)_0%,transparent_70%)]" />
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 text-accent text-sm mb-6">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span>Real-time Market Data</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-foreground mb-6 tracking-tight">
|
||||
TLD Pricing
|
||||
<span className="block text-accent">& Trends</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto mb-8">
|
||||
Track prices, renewal traps, and trends across {total > 0 ? total.toLocaleString() : '800+'} TLDs.
|
||||
Make informed decisions with real market data.
|
||||
</p>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 max-w-3xl mx-auto mb-8">
|
||||
<div className="bg-background-secondary/50 border border-border/50 rounded-2xl p-4">
|
||||
<Globe className="w-5 h-5 text-accent mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-foreground">{total > 0 ? total.toLocaleString() : '—'}</div>
|
||||
<div className="text-xs text-foreground-muted">TLDs Tracked</div>
|
||||
</div>
|
||||
<div className="bg-background-secondary/50 border border-border/50 rounded-2xl p-4">
|
||||
<DollarSign className="w-5 h-5 text-foreground-muted mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-foreground">{total > 0 ? `$${lowestPrice.toFixed(2)}` : '—'}</div>
|
||||
<div className="text-xs text-foreground-muted">Lowest Price</div>
|
||||
</div>
|
||||
<div className="bg-background-secondary/50 border border-border/50 rounded-2xl p-4">
|
||||
<TrendingUp className="w-5 h-5 text-orange-400 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-foreground">.{hottestTld}</div>
|
||||
<div className="text-xs text-foreground-muted">Hottest TLD</div>
|
||||
</div>
|
||||
<div className="bg-background-secondary/50 border border-border/50 rounded-2xl p-4">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-foreground">{trapCount}</div>
|
||||
<div className="text-xs text-foreground-muted">Renewal Traps</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main Content */}
|
||||
<section className="py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-6">
|
||||
{/* Category Tabs */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{(Object.keys(CATEGORIES) as CategoryKey[]).map((key) => {
|
||||
const cat = CATEGORIES[key]
|
||||
const Icon = cat.icon
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setCategory(key)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium whitespace-nowrap transition-all",
|
||||
category === key
|
||||
? "bg-accent/10 text-accent border border-accent/20"
|
||||
: "bg-foreground/5 text-foreground-muted hover:text-foreground hover:bg-foreground/10 border border-transparent"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{cat.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search TLDs (e.g. com, io, dev)..."
|
||||
className="w-full h-11 pl-11 pr-10 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||
text-sm text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-accent/50 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="h-11 pl-4 pr-10 bg-background-secondary/50 border border-border/50 rounded-xl
|
||||
text-sm text-foreground appearance-none cursor-pointer
|
||||
focus:outline-none focus:border-accent/50"
|
||||
>
|
||||
<option value="popularity">By Popularity</option>
|
||||
<option value="price_asc">Price: Low → High</option>
|
||||
<option value="price_desc">Price: High → Low</option>
|
||||
<option value="change">By Price Change</option>
|
||||
</select>
|
||||
<ArrowUpDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted pointer-events-none" />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground
|
||||
bg-foreground/5 hover:bg-foreground/10 rounded-xl transition-all disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-foreground-muted">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Renewal trap: Renewal >2x registration</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<span>Risk levels: 🟢 Low, 🟡 Medium, 🔴 High</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TLD Table */}
|
||||
<div className="relative">
|
||||
<div className="bg-background-secondary/30 border border-border/50 rounded-2xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/30">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-foreground-muted uppercase tracking-wider w-24">TLD</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-foreground-muted uppercase tracking-wider w-20 hidden sm:table-cell">Trend</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-foreground-muted uppercase tracking-wider w-24">Buy (1y)</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-foreground-muted uppercase tracking-wider w-28">Renew (1y)</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-foreground-muted uppercase tracking-wider w-24 hidden md:table-cell">1y Change</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-foreground-muted uppercase tracking-wider w-28">Risk</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-foreground-muted uppercase tracking-wider w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
Array.from({ length: 10 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border/20">
|
||||
<td colSpan={7} className="px-4 py-4">
|
||||
<div className="h-4 bg-foreground/5 rounded animate-pulse" />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : filteredData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-12 text-center text-foreground-muted">
|
||||
<Globe className="w-12 h-12 mx-auto mb-3 text-foreground-subtle" />
|
||||
<p className="font-medium">No TLDs found</p>
|
||||
<p className="text-sm mt-1">{searchQuery ? `No results for "${searchQuery}"` : 'Check back later'}</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredData.map((tld, index) => {
|
||||
const isBlurred = !isAuthenticated && index >= FREE_VISIBLE_ROWS
|
||||
const change1y = tld.price_change_1y || 0
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={tld.tld}
|
||||
className={clsx(
|
||||
"border-b border-border/20 group transition-colors",
|
||||
isBlurred ? "opacity-50" : "hover:bg-foreground/[0.02] cursor-pointer"
|
||||
)}
|
||||
onClick={() => !isBlurred && (window.location.href = `/tld-pricing/${tld.tld}`)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||
.{tld.tld}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
{isBlurred ? (
|
||||
<div className="w-10 h-4 bg-foreground/10 rounded blur-[2px]" />
|
||||
) : (
|
||||
<Sparkline trend={change1y} />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="font-semibold text-foreground tabular-nums">${tld.min_price.toFixed(2)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{isBlurred ? (
|
||||
<span className="text-foreground-muted blur-[3px] select-none tabular-nums">$XX.XX</span>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right hidden md:table-cell">
|
||||
{isBlurred ? (
|
||||
<span className="text-foreground-muted blur-[3px] select-none">+XX%</span>
|
||||
) : (
|
||||
<span className={clsx(
|
||||
"font-medium tabular-nums",
|
||||
change1y > 0 ? "text-orange-400" : change1y < 0 ? "text-accent" : "text-foreground-muted"
|
||||
)}>
|
||||
{change1y > 0 ? '+' : ''}{change1y.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{getRiskBadge(tld, isBlurred)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{isBlurred ? (
|
||||
<Lock className="w-4 h-4 text-foreground-subtle inline" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors inline" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blur overlay CTA for non-authenticated */}
|
||||
{!isAuthenticated && filteredData.length > FREE_VISIBLE_ROWS && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-48 bg-gradient-to-t from-background via-background/95 to-transparent flex items-end justify-center pb-8 pointer-events-none">
|
||||
<div className="pointer-events-auto text-center">
|
||||
<p className="text-foreground-muted mb-4">
|
||||
Stop overpaying. Unlock renewal prices, trends & risk analysis for {total}+ TLDs.
|
||||
</p>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-semibold rounded-xl
|
||||
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Start Free
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination - only for authenticated */}
|
||||
{isAuthenticated && total > 50 && (
|
||||
<div className="flex items-center justify-center gap-4 pt-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="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"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-foreground-muted tabular-nums">
|
||||
Page {page + 1} of {Math.ceil(total / 50)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={(page + 1) * 50 >= total}
|
||||
className="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
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login CTA Banner */}
|
||||
{!isAuthenticated && (
|
||||
<div className="mt-8 p-6 bg-gradient-to-r from-accent/10 to-accent/5 border border-accent/20 rounded-2xl text-center">
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">
|
||||
🔍 See the Full Picture
|
||||
</h3>
|
||||
<p className="text-foreground-muted mb-4 max-w-xl mx-auto">
|
||||
Unlock renewal traps, 3-year trends, price alerts, and our risk analysis across 800+ TLDs.
|
||||
Make data-driven domain decisions.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-semibold rounded-xl
|
||||
hover:bg-accent-hover transition-all"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Create Free Account
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 text-foreground-muted hover:text-foreground
|
||||
border border-border rounded-xl hover:bg-foreground/5 transition-all"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -30,8 +30,15 @@ interface TldData {
|
||||
avg_registration_price: number
|
||||
min_registration_price: number
|
||||
max_registration_price: number
|
||||
min_renewal_price: number
|
||||
avg_renewal_price: number
|
||||
registrar_count: number
|
||||
trend: string
|
||||
price_change_7d: number
|
||||
price_change_1y: number
|
||||
price_change_3y: number
|
||||
risk_level: 'low' | 'medium' | 'high'
|
||||
risk_reason: string
|
||||
popularity_rank?: number
|
||||
}
|
||||
|
||||
@ -409,8 +416,8 @@ export default function TldPricingPage() {
|
||||
<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 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
|
||||
@ -430,8 +437,14 @@ export default function TldPricingPage() {
|
||||
<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">Trend</span>
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium">Risk</span>
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-4"></th>
|
||||
</tr>
|
||||
@ -444,16 +457,18 @@ export default function TldPricingPage() {
|
||||
<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 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={8} className="px-6 py-12 text-center text-foreground-muted">
|
||||
<td colSpan={10} className="px-6 py-12 text-center text-foreground-muted">
|
||||
{searchQuery ? `No TLDs found matching "${searchQuery}"` : 'No TLDs found'}
|
||||
</td>
|
||||
</tr>
|
||||
@ -494,7 +509,7 @@ export default function TldPricingPage() {
|
||||
{tld.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden md:table-cell">
|
||||
<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">
|
||||
@ -515,8 +530,48 @@ export default function TldPricingPage() {
|
||||
<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 ? getTrendIcon(tld.trend) : <Minus className="w-4 h-4 text-foreground-subtle mx-auto" />}
|
||||
{showFullData ? (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
tld.risk_level === 'high' && "bg-red-500/10 text-red-400",
|
||||
tld.risk_level === 'medium' && "bg-amber-500/10 text-amber-400",
|
||||
tld.risk_level === 'low' && "bg-accent/10 text-accent"
|
||||
)}>
|
||||
{tld.risk_level === 'high' && '🔴'}
|
||||
{tld.risk_level === 'medium' && '🟡'}
|
||||
{tld.risk_level === 'low' && '🟢'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-foreground-subtle blur-[3px]">🟢</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<Link
|
||||
|
||||
Reference in New Issue
Block a user