Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Renamed /intel to /discover - Updated styles to match dark/cinematic landing page theme - Updated Header, Footer, and Sitemap - Added redirects from /intel and /tld-pricing to /discover - Optimized SEO metadata for new paths
388 lines
13 KiB
TypeScript
Executable File
388 lines
13 KiB
TypeScript
Executable File
'use client'
|
||
|
||
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
||
import { useStore } from '@/lib/store'
|
||
import { api } from '@/lib/api'
|
||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||
import {
|
||
PremiumTable,
|
||
StatCard,
|
||
PageContainer,
|
||
SearchInput,
|
||
TabBar,
|
||
FilterBar,
|
||
SelectDropdown,
|
||
ActionButton,
|
||
} from '@/components/PremiumTable'
|
||
import {
|
||
TrendingUp,
|
||
ChevronRight,
|
||
Globe,
|
||
DollarSign,
|
||
RefreshCw,
|
||
AlertTriangle,
|
||
Cpu,
|
||
MapPin,
|
||
Coins,
|
||
Crown,
|
||
Info,
|
||
Loader2,
|
||
} from 'lucide-react'
|
||
import clsx from 'clsx'
|
||
|
||
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
|
||
}
|
||
|
||
// Category definitions
|
||
const CATEGORIES = [
|
||
{ id: 'all', label: 'All', icon: Globe },
|
||
{ id: 'tech', label: 'Tech', icon: Cpu },
|
||
{ id: 'geo', label: 'Geo', icon: MapPin },
|
||
{ id: 'budget', label: 'Budget', icon: Coins },
|
||
{ id: 'premium', label: 'Premium', icon: Crown },
|
||
]
|
||
|
||
const CATEGORY_FILTERS: Record<string, (tld: TLDData) => boolean> = {
|
||
all: () => true,
|
||
tech: (tld) => ['ai', 'io', 'app', 'dev', 'tech', 'code', 'cloud', 'data', 'api', 'software'].includes(tld.tld),
|
||
geo: (tld) => ['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: (tld) => tld.min_price < 5,
|
||
premium: (tld) => tld.min_price >= 50,
|
||
}
|
||
|
||
const SORT_OPTIONS = [
|
||
{ value: 'popularity', label: 'By Popularity' },
|
||
{ value: 'price_asc', label: 'Price: Low → High' },
|
||
{ value: 'price_desc', label: 'Price: High → Low' },
|
||
{ value: 'change', label: 'By Price Change' },
|
||
{ value: 'risk', label: 'By Risk Level' },
|
||
]
|
||
|
||
// Memoized Sparkline
|
||
const Sparkline = memo(function Sparkline({ trend }: { trend: number }) {
|
||
const isPositive = trend > 0
|
||
const isNeutral = trend === 0
|
||
|
||
return (
|
||
<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>
|
||
)
|
||
})
|
||
|
||
export default function TLDPricingPage() {
|
||
const { subscription } = 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')
|
||
const [category, setCategory] = useState('all')
|
||
const [page, setPage] = useState(0)
|
||
const [total, setTotal] = useState(0)
|
||
|
||
const loadTLDData = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
const response = await api.getTldOverview(
|
||
50,
|
||
page * 50,
|
||
sortBy === 'risk' || sortBy === 'change' ? 'popularity' : sortBy as any,
|
||
)
|
||
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)
|
||
}
|
||
}, [page, sortBy])
|
||
|
||
useEffect(() => {
|
||
loadTLDData()
|
||
}, [loadTLDData])
|
||
|
||
const handleRefresh = useCallback(async () => {
|
||
setRefreshing(true)
|
||
await loadTLDData()
|
||
setRefreshing(false)
|
||
}, [loadTLDData])
|
||
|
||
// Memoized filtered and sorted data
|
||
const sortedData = useMemo(() => {
|
||
let data = tldData.filter(CATEGORY_FILTERS[category] || (() => true))
|
||
|
||
if (searchQuery) {
|
||
const q = searchQuery.toLowerCase()
|
||
data = data.filter(tld => tld.tld.toLowerCase().includes(q))
|
||
}
|
||
|
||
if (sortBy === 'risk') {
|
||
const riskOrder = { high: 0, medium: 1, low: 2 }
|
||
data = [...data].sort((a, b) => riskOrder[a.risk_level] - riskOrder[b.risk_level])
|
||
}
|
||
|
||
return data
|
||
}, [tldData, category, searchQuery, sortBy])
|
||
|
||
// Memoized stats
|
||
const stats = useMemo(() => {
|
||
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
|
||
return { lowestPrice, hottestTld, trapCount }
|
||
}, [tldData])
|
||
|
||
const subtitle = useMemo(() => {
|
||
if (loading && total === 0) return 'Loading TLD pricing data...'
|
||
if (total === 0) return 'No TLD data available'
|
||
return `Tracking ${total.toLocaleString()} TLDs • Updated daily`
|
||
}, [loading, total])
|
||
|
||
// Memoized columns
|
||
const columns = useMemo(() => [
|
||
{
|
||
key: 'tld',
|
||
header: 'TLD',
|
||
width: '100px',
|
||
render: (tld: TLDData) => (
|
||
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
||
.{tld.tld}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'trend',
|
||
header: 'Trend',
|
||
width: '80px',
|
||
hideOnMobile: true,
|
||
render: (tld: TLDData) => <Sparkline trend={tld.price_change_1y || 0} />,
|
||
},
|
||
{
|
||
key: 'buy_price',
|
||
header: 'Buy (1y)',
|
||
align: 'right' as const,
|
||
width: '100px',
|
||
render: (tld: TLDData) => (
|
||
<span className="font-semibold text-foreground tabular-nums">${tld.min_price.toFixed(2)}</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'renew_price',
|
||
header: 'Renew (1y)',
|
||
align: 'right' as const,
|
||
width: '120px',
|
||
render: (tld: TLDData) => {
|
||
const ratio = tld.min_renewal_price / tld.min_price
|
||
return (
|
||
<div className="flex items-center gap-1 justify-end">
|
||
<span className="text-foreground-muted tabular-nums">${tld.min_renewal_price.toFixed(2)}</span>
|
||
{ratio > 2 && (
|
||
<span className="text-amber-400" title={`Renewal is ${ratio.toFixed(1)}x registration`}>
|
||
<AlertTriangle className="w-3.5 h-3.5" />
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
key: 'change_1y',
|
||
header: '1y',
|
||
align: 'right' as const,
|
||
width: '80px',
|
||
hideOnMobile: true,
|
||
render: (tld: TLDData) => {
|
||
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',
|
||
align: 'right' as const,
|
||
width: '80px',
|
||
hideOnMobile: true,
|
||
render: (tld: TLDData) => {
|
||
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' as const,
|
||
width: '120px',
|
||
render: (tld: TLDData) => (
|
||
<span className={clsx(
|
||
"inline-flex items-center gap-1.5 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 h-2 rounded-full",
|
||
tld.risk_level === 'high' && "bg-red-400",
|
||
tld.risk_level === 'medium' && "bg-amber-400",
|
||
tld.risk_level === 'low' && "bg-accent"
|
||
)} />
|
||
<span className="hidden sm:inline">{tld.risk_reason}</span>
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'actions',
|
||
header: '',
|
||
align: 'right' as const,
|
||
width: '50px',
|
||
render: () => <ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />,
|
||
},
|
||
], [])
|
||
|
||
return (
|
||
<CommandCenterLayout
|
||
title="TLD Pricing"
|
||
subtitle={subtitle}
|
||
actions={
|
||
<ActionButton onClick={handleRefresh} disabled={refreshing} variant="ghost" icon={refreshing ? Loader2 : RefreshCw}>
|
||
{refreshing ? '' : 'Refresh'}
|
||
</ActionButton>
|
||
}
|
||
>
|
||
<PageContainer>
|
||
{/* Stats Overview */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<StatCard title="TLDs Tracked" value={total > 0 ? total.toLocaleString() : '—'} subtitle="updated daily" icon={Globe} />
|
||
<StatCard title="Lowest Price" value={total > 0 ? `$${stats.lowestPrice.toFixed(2)}` : '—'} icon={DollarSign} />
|
||
<StatCard title="Hottest TLD" value={total > 0 ? `.${stats.hottestTld}` : '—'} subtitle="rising prices" icon={TrendingUp} />
|
||
<StatCard title="Renewal Traps" value={stats.trapCount.toString()} subtitle="high renewal ratio" icon={AlertTriangle} />
|
||
</div>
|
||
|
||
{/* Category Tabs */}
|
||
<TabBar
|
||
tabs={CATEGORIES.map(c => ({ id: c.id, label: c.label, icon: c.icon }))}
|
||
activeTab={category}
|
||
onChange={setCategory}
|
||
/>
|
||
|
||
{/* Filters */}
|
||
<FilterBar>
|
||
<SearchInput
|
||
value={searchQuery}
|
||
onChange={setSearchQuery}
|
||
placeholder="Search TLDs (e.g. com, io, dev)..."
|
||
className="flex-1 max-w-md"
|
||
/>
|
||
<SelectDropdown value={sortBy} onChange={setSortBy} options={SORT_OPTIONS} />
|
||
</FilterBar>
|
||
|
||
{/* Legend */}
|
||
<div className="flex flex-wrap items-center gap-4 text-xs text-foreground-muted">
|
||
<div className="flex items-center gap-2">
|
||
<Info className="w-3.5 h-3.5" />
|
||
<span>Tip: Renewal traps show ⚠️ when renewal price is >2x registration</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* TLD Table */}
|
||
<PremiumTable
|
||
data={sortedData}
|
||
keyExtractor={(tld) => tld.tld}
|
||
loading={loading}
|
||
onRowClick={(tld) => window.location.href = `/command/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={columns}
|
||
/>
|
||
|
||
{/* Pagination */}
|
||
{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>
|
||
)}
|
||
</PageContainer>
|
||
</CommandCenterLayout>
|
||
)
|
||
}
|