PAGE HEADERS: - All pages now show dynamic, context-aware subtitles - Dashboard: Time-based greeting + status summary - Watchlist: Domain count + slots remaining - Portfolio: Profit/loss summary - Auctions: Live count across platforms - Intelligence: TLD count or loading state TABLE IMPROVEMENTS: - Fixed column alignments with table-fixed layout - Added width constraints for consistent column sizing - Better header button alignment for sortable columns - tabular-nums for numeric values DOMAIN HEALTH INTEGRATION: - Added getDomainHealth and quickHealthCheck API methods - Health status types (healthy, weakening, parked, critical) - Health check button in watchlist with modal view - 4-layer analysis display (DNS, HTTP, SSL, recommendations) - Optional chaining to prevent undefined errors UI FIXES: - Consistent card and section styling - Improved filter/tab buttons - Better empty states - Search with clear button
299 lines
10 KiB
TypeScript
Executable File
299 lines
10 KiB
TypeScript
Executable File
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { useStore } from '@/lib/store'
|
|
import { api } from '@/lib/api'
|
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
|
import { PremiumTable, Badge, StatCard, PageContainer } from '@/components/PremiumTable'
|
|
import {
|
|
Search,
|
|
TrendingUp,
|
|
TrendingDown,
|
|
Minus,
|
|
ChevronRight,
|
|
Globe,
|
|
ArrowUpDown,
|
|
DollarSign,
|
|
BarChart3,
|
|
RefreshCw,
|
|
Bell,
|
|
X,
|
|
} from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
import Link from 'next/link'
|
|
|
|
interface TLDData {
|
|
tld: string
|
|
min_price: number
|
|
avg_price: number
|
|
max_price: number
|
|
cheapest_registrar: string
|
|
cheapest_registrar_url?: string
|
|
price_change_7d?: number
|
|
popularity_rank?: number
|
|
}
|
|
|
|
export default function IntelligencePage() {
|
|
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' | 'price_asc' | 'price_desc' | 'change'>('popularity')
|
|
const [page, setPage] = useState(0)
|
|
const [total, setTotal] = useState(0)
|
|
|
|
useEffect(() => {
|
|
loadTLDData()
|
|
}, [page, sortBy])
|
|
|
|
const loadTLDData = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const response = await api.getTldPrices({
|
|
limit: 50,
|
|
offset: page * 50,
|
|
sort_by: sortBy,
|
|
})
|
|
setTldData(response.tlds || [])
|
|
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)
|
|
}
|
|
|
|
// Filter by search
|
|
const filteredData = tldData.filter(tld =>
|
|
tld.tld.toLowerCase().includes(searchQuery.toLowerCase())
|
|
)
|
|
|
|
const getTrendIcon = (change: number | undefined) => {
|
|
if (!change || change === 0) return <Minus className="w-4 h-4 text-foreground-muted" />
|
|
if (change > 0) return <TrendingUp className="w-4 h-4 text-orange-400" />
|
|
return <TrendingDown className="w-4 h-4 text-accent" />
|
|
}
|
|
|
|
// 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) > 0)?.tld || 'com'
|
|
|
|
// Dynamic subtitle
|
|
const getSubtitle = () => {
|
|
if (loading && total === 0) return 'Loading TLD pricing data...'
|
|
if (total === 0) return 'No TLD data available'
|
|
return `Comparing prices across ${total.toLocaleString()} TLDs`
|
|
}
|
|
|
|
return (
|
|
<CommandCenterLayout
|
|
title="TLD Intelligence"
|
|
subtitle={getSubtitle()}
|
|
actions={
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={refreshing}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm text-foreground-muted hover:text-foreground
|
|
hover:bg-foreground/5 rounded-lg transition-all disabled:opacity-50"
|
|
>
|
|
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
|
<span className="hidden sm:inline">Refresh</span>
|
|
</button>
|
|
}
|
|
>
|
|
<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 ? `$${lowestPrice.toFixed(2)}` : '—'}
|
|
icon={DollarSign}
|
|
/>
|
|
<StatCard
|
|
title="Hottest TLD"
|
|
value={total > 0 ? `.${hottestTld}` : '—'}
|
|
subtitle="rising prices"
|
|
icon={TrendingUp}
|
|
/>
|
|
<StatCard
|
|
title="Update Freq"
|
|
value="24h"
|
|
subtitle="automatic"
|
|
icon={BarChart3}
|
|
/>
|
|
</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>
|
|
</div>
|
|
|
|
{/* TLD Table */}
|
|
<PremiumTable
|
|
data={filteredData}
|
|
keyExtractor={(tld) => tld.tld}
|
|
loading={loading}
|
|
onRowClick={(tld) => window.location.href = `/tld-pricing/${tld.tld}`}
|
|
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />}
|
|
emptyTitle="No TLDs found"
|
|
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
|
|
columns={[
|
|
{
|
|
key: 'tld',
|
|
header: 'TLD',
|
|
width: '120px',
|
|
render: (tld) => (
|
|
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
|
.{tld.tld}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'min_price',
|
|
header: 'Min Price',
|
|
align: 'right',
|
|
width: '100px',
|
|
render: (tld) => (
|
|
<span className="font-medium text-foreground tabular-nums">${tld.min_price.toFixed(2)}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'avg_price',
|
|
header: 'Avg Price',
|
|
align: 'right',
|
|
width: '100px',
|
|
hideOnMobile: true,
|
|
render: (tld) => (
|
|
<span className="text-foreground-muted tabular-nums">${tld.avg_price.toFixed(2)}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'change',
|
|
header: '7d Change',
|
|
align: 'right',
|
|
width: '120px',
|
|
render: (tld) => (
|
|
<div className="flex items-center gap-2 justify-end">
|
|
{getTrendIcon(tld.price_change_7d)}
|
|
<span className={clsx(
|
|
"font-medium tabular-nums",
|
|
(tld.price_change_7d || 0) > 0 ? "text-orange-400" :
|
|
(tld.price_change_7d || 0) < 0 ? "text-accent" : "text-foreground-muted"
|
|
)}>
|
|
{(tld.price_change_7d || 0) > 0 ? '+' : ''}{(tld.price_change_7d || 0).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'registrar',
|
|
header: 'Cheapest At',
|
|
hideOnMobile: true,
|
|
render: (tld) => (
|
|
<span className="text-foreground-muted text-sm truncate max-w-[150px] block">{tld.cheapest_registrar}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
align: 'right',
|
|
width: '80px',
|
|
render: (tld) => (
|
|
<div className="flex items-center gap-1 justify-end">
|
|
<Link
|
|
href={`/tld-pricing/${tld.tld}`}
|
|
className="p-2 text-foreground-subtle hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
|
onClick={(e) => e.stopPropagation()}
|
|
title="Set price alert"
|
|
>
|
|
<Bell className="w-4 h-4" />
|
|
</Link>
|
|
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
|
|
</div>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{/* 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>
|
|
)
|
|
}
|