yves.gugger b5c456af1c
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
refactor: Rename Intel to Discover and apply Landing Page style
- 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
2025-12-12 16:35:34 +01:00

388 lines
13 KiB
TypeScript
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 &gt;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>
)
}