yves.gugger 0bb2b6fc9d perf: Optimize all Command Center pages for performance
LAYOUT CONSISTENCY:
- Header and content now use same max-width (max-w-7xl)
- All pages use consistent PageContainer wrapper
- Unified spacing and padding

NEW REUSABLE COMPONENTS (PremiumTable.tsx):
- SearchInput: Consistent search box styling
- TabBar: Consistent tabs with counts and icons
- FilterBar: Flex container for filter rows
- SelectDropdown: Consistent dropdown styling
- ActionButton: Consistent button (primary/secondary/ghost)

PERFORMANCE OPTIMIZATIONS:

1. Watchlist Page:
   - useMemo for stats, filtered domains, columns
   - useCallback for all handlers
   - memo() for HealthReportModal

2. Auctions Page:
   - useMemo for tabs, sorted auctions
   - useCallback for handlers
   - Pure functions for calculations

3. TLD Pricing Page:
   - useMemo for filtered data, stats, columns
   - useCallback for data loading
   - memo() for Sparkline component

4. Portfolio Page:
   - useMemo for expiringSoonCount, subtitle
   - useCallback for all CRUD handlers
   - Uses new ActionButton

5. Alerts Page:
   - useMemo for stats
   - useCallback for all handlers
   - Uses new ActionButton

6. Marketplace/Listings Pages:
   - useMemo for filtered/sorted listings, stats
   - useCallback for data loading
   - Uses new components

7. Dashboard Page:
   - useMemo for computed values (greeting, subtitle, etc.)
   - useCallback for data loading

8. Settings Page:
   - Added TabBar import for future use
   - Added useCallback, useMemo imports

RESULT:
- Reduced unnecessary re-renders
- Memoized expensive calculations
- Consistent visual styling across all pages
- Better mobile responsiveness
2025-12-10 16:47:38 +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} accent />
<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>
)
}