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
This commit is contained in:
@ -1,10 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
import { PageContainer, StatCard, Badge } from '@/components/PremiumTable'
|
import { PageContainer, StatCard, Badge, ActionButton } from '@/components/PremiumTable'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Bell,
|
Bell,
|
||||||
@ -12,14 +12,12 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
Loader2,
|
Loader2,
|
||||||
Trash2,
|
Trash2,
|
||||||
Edit2,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
X,
|
X,
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
Mail,
|
Mail,
|
||||||
Smartphone,
|
|
||||||
Settings,
|
Settings,
|
||||||
TestTube,
|
TestTube,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@ -98,11 +96,7 @@ export default function SniperAlertsPage() {
|
|||||||
notify_email: true,
|
notify_email: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
const loadAlerts = useCallback(async () => {
|
||||||
loadAlerts()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadAlerts = async () => {
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await api.request<SniperAlert[]>('/sniper-alerts')
|
const data = await api.request<SniperAlert[]>('/sniper-alerts')
|
||||||
@ -112,9 +106,13 @@ export default function SniperAlertsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
useEffect(() => {
|
||||||
|
loadAlerts()
|
||||||
|
}, [loadAlerts])
|
||||||
|
|
||||||
|
const handleCreate = useCallback(async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setCreating(true)
|
setCreating(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@ -152,9 +150,9 @@ export default function SniperAlertsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
}
|
}
|
||||||
}
|
}, [newAlert, loadAlerts])
|
||||||
|
|
||||||
const handleToggle = async (alert: SniperAlert) => {
|
const handleToggle = useCallback(async (alert: SniperAlert) => {
|
||||||
try {
|
try {
|
||||||
await api.request(`/sniper-alerts/${alert.id}`, {
|
await api.request(`/sniper-alerts/${alert.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@ -164,9 +162,9 @@ export default function SniperAlertsPage() {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
}
|
}
|
||||||
}
|
}, [loadAlerts])
|
||||||
|
|
||||||
const handleDelete = async (alert: SniperAlert) => {
|
const handleDelete = useCallback(async (alert: SniperAlert) => {
|
||||||
if (!confirm(`Delete alert "${alert.name}"?`)) return
|
if (!confirm(`Delete alert "${alert.name}"?`)) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -176,9 +174,9 @@ export default function SniperAlertsPage() {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
}
|
}
|
||||||
}
|
}, [loadAlerts])
|
||||||
|
|
||||||
const handleTest = async (alert: SniperAlert) => {
|
const handleTest = useCallback(async (alert: SniperAlert) => {
|
||||||
setTesting(alert.id)
|
setTesting(alert.id)
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
|
|
||||||
@ -193,7 +191,14 @@ export default function SniperAlertsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setTesting(null)
|
setTesting(null)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
|
// Memoized stats
|
||||||
|
const stats = useMemo(() => ({
|
||||||
|
activeAlerts: alerts.filter(a => a.is_active).length,
|
||||||
|
totalMatches: alerts.reduce((sum, a) => sum + a.matches_count, 0),
|
||||||
|
notificationsSent: alerts.reduce((sum, a) => sum + a.notifications_sent, 0),
|
||||||
|
}), [alerts])
|
||||||
|
|
||||||
const tier = subscription?.tier || 'scout'
|
const tier = subscription?.tier || 'scout'
|
||||||
const limits = { scout: 2, trader: 10, tycoon: 50 }
|
const limits = { scout: 2, trader: 10, tycoon: 50 }
|
||||||
@ -204,15 +209,9 @@ export default function SniperAlertsPage() {
|
|||||||
title="Sniper Alerts"
|
title="Sniper Alerts"
|
||||||
subtitle={`Hyper-personalized auction notifications (${alerts.length}/${maxAlerts})`}
|
subtitle={`Hyper-personalized auction notifications (${alerts.length}/${maxAlerts})`}
|
||||||
actions={
|
actions={
|
||||||
<button
|
<ActionButton onClick={() => setShowCreateModal(true)} disabled={alerts.length >= maxAlerts} icon={Plus}>
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
disabled={alerts.length >= maxAlerts}
|
|
||||||
className="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 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
New Alert
|
New Alert
|
||||||
</button>
|
</ActionButton>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@ -235,9 +234,9 @@ export default function SniperAlertsPage() {
|
|||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard title="Active Alerts" value={alerts.filter(a => a.is_active).length} icon={Bell} />
|
<StatCard title="Active Alerts" value={stats.activeAlerts} icon={Bell} />
|
||||||
<StatCard title="Total Matches" value={alerts.reduce((sum, a) => sum + a.matches_count, 0)} icon={Target} />
|
<StatCard title="Total Matches" value={stats.totalMatches} icon={Target} />
|
||||||
<StatCard title="Notifications Sent" value={alerts.reduce((sum, a) => sum + a.notifications_sent, 0)} icon={Zap} />
|
<StatCard title="Notifications Sent" value={stats.notificationsSent} icon={Zap} />
|
||||||
<StatCard title="Alert Slots" value={`${alerts.length}/${maxAlerts}`} icon={Settings} />
|
<StatCard title="Alert Slots" value={`${alerts.length}/${maxAlerts}`} icon={Settings} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,29 +1,33 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from 'react'
|
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
import { PremiumTable, Badge, PlatformBadge, StatCard, PageContainer } from '@/components/PremiumTable'
|
import {
|
||||||
|
PremiumTable,
|
||||||
|
Badge,
|
||||||
|
PlatformBadge,
|
||||||
|
StatCard,
|
||||||
|
PageContainer,
|
||||||
|
SearchInput,
|
||||||
|
TabBar,
|
||||||
|
FilterBar,
|
||||||
|
SelectDropdown,
|
||||||
|
ActionButton,
|
||||||
|
} from '@/components/PremiumTable'
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Search,
|
|
||||||
Flame,
|
Flame,
|
||||||
Timer,
|
Timer,
|
||||||
Gavel,
|
Gavel,
|
||||||
ChevronUp,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronsUpDown,
|
|
||||||
DollarSign,
|
DollarSign,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Target,
|
Target,
|
||||||
X,
|
|
||||||
TrendingUp,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Eye,
|
Eye,
|
||||||
Filter,
|
|
||||||
Zap,
|
Zap,
|
||||||
Crown,
|
Crown,
|
||||||
Plus,
|
Plus,
|
||||||
@ -66,15 +70,14 @@ type SortField = 'ending' | 'bid_asc' | 'bid_desc' | 'bids' | 'score'
|
|||||||
type FilterPreset = 'all' | 'no-trash' | 'short' | 'high-value' | 'low-competition'
|
type FilterPreset = 'all' | 'no-trash' | 'short' | 'high-value' | 'low-competition'
|
||||||
|
|
||||||
const PLATFORMS = [
|
const PLATFORMS = [
|
||||||
{ id: 'All', name: 'All Sources' },
|
{ value: 'All', label: 'All Sources' },
|
||||||
{ id: 'GoDaddy', name: 'GoDaddy' },
|
{ value: 'GoDaddy', label: 'GoDaddy' },
|
||||||
{ id: 'Sedo', name: 'Sedo' },
|
{ value: 'Sedo', label: 'Sedo' },
|
||||||
{ id: 'NameJet', name: 'NameJet' },
|
{ value: 'NameJet', label: 'NameJet' },
|
||||||
{ id: 'DropCatch', name: 'DropCatch' },
|
{ value: 'DropCatch', label: 'DropCatch' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Smart Filter Presets (from GAP_ANALYSIS.md)
|
const FILTER_PRESETS: { id: FilterPreset, label: string, icon: typeof Gavel, description: string, proOnly?: boolean }[] = [
|
||||||
const FILTER_PRESETS: { id: FilterPreset, label: string, icon: typeof Filter, description: string, proOnly?: boolean }[] = [
|
|
||||||
{ id: 'all', label: 'All', icon: Gavel, description: 'Show all auctions' },
|
{ id: 'all', label: 'All', icon: Gavel, description: 'Show all auctions' },
|
||||||
{ id: 'no-trash', label: 'No Trash', icon: Sparkles, description: 'Clean domains only (no spam)', proOnly: true },
|
{ id: 'no-trash', label: 'No Trash', icon: Sparkles, description: 'Clean domains only (no spam)', proOnly: true },
|
||||||
{ id: 'short', label: 'Short', icon: Zap, description: '4-letter domains or less' },
|
{ id: 'short', label: 'Short', icon: Zap, description: '4-letter domains or less' },
|
||||||
@ -82,56 +85,49 @@ const FILTER_PRESETS: { id: FilterPreset, label: string, icon: typeof Filter, de
|
|||||||
{ id: 'low-competition', label: 'Low Competition', icon: Target, description: 'Under 5 bids' },
|
{ id: 'low-competition', label: 'Low Competition', icon: Target, description: 'Under 5 bids' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Premium TLDs for filtering
|
|
||||||
const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'net', 'org', 'app', 'dev']
|
const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'net', 'org', 'app', 'dev']
|
||||||
|
|
||||||
// Vanity/Clean domain check (no trash)
|
// Pure functions (no hooks needed)
|
||||||
function isCleanDomain(auction: Auction): boolean {
|
function isCleanDomain(auction: Auction): boolean {
|
||||||
const name = auction.domain.split('.')[0]
|
const name = auction.domain.split('.')[0]
|
||||||
|
|
||||||
// No hyphens
|
|
||||||
if (name.includes('-')) return false
|
if (name.includes('-')) return false
|
||||||
|
|
||||||
// No numbers (unless short)
|
|
||||||
if (name.length > 4 && /\d/.test(name)) return false
|
if (name.length > 4 && /\d/.test(name)) return false
|
||||||
|
|
||||||
// Max 12 chars
|
|
||||||
if (name.length > 12) return false
|
if (name.length > 12) return false
|
||||||
|
|
||||||
// Premium TLD only
|
|
||||||
if (!PREMIUM_TLDS.includes(auction.tld)) return false
|
if (!PREMIUM_TLDS.includes(auction.tld)) return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate Deal Score for an auction
|
|
||||||
function calculateDealScore(auction: Auction): number {
|
function calculateDealScore(auction: Auction): number {
|
||||||
let score = 50
|
let score = 50
|
||||||
|
|
||||||
// Short domains are more valuable
|
|
||||||
const name = auction.domain.split('.')[0]
|
const name = auction.domain.split('.')[0]
|
||||||
if (name.length <= 4) score += 25
|
if (name.length <= 4) score += 25
|
||||||
else if (name.length <= 6) score += 15
|
else if (name.length <= 6) score += 15
|
||||||
else if (name.length <= 8) score += 5
|
else if (name.length <= 8) score += 5
|
||||||
|
|
||||||
// Premium TLDs
|
|
||||||
if (['com', 'io', 'ai'].includes(auction.tld)) score += 15
|
if (['com', 'io', 'ai'].includes(auction.tld)) score += 15
|
||||||
else if (['co', 'net', 'org'].includes(auction.tld)) score += 5
|
else if (['co', 'net', 'org'].includes(auction.tld)) score += 5
|
||||||
|
|
||||||
// Age bonus
|
|
||||||
if (auction.age_years && auction.age_years > 10) score += 15
|
if (auction.age_years && auction.age_years > 10) score += 15
|
||||||
else if (auction.age_years && auction.age_years > 5) score += 10
|
else if (auction.age_years && auction.age_years > 5) score += 10
|
||||||
|
|
||||||
// High competition = good domain
|
|
||||||
if (auction.num_bids >= 20) score += 10
|
if (auction.num_bids >= 20) score += 10
|
||||||
else if (auction.num_bids >= 10) score += 5
|
else if (auction.num_bids >= 10) score += 5
|
||||||
|
|
||||||
// Clean domain bonus
|
|
||||||
if (isCleanDomain(auction)) score += 10
|
if (isCleanDomain(auction)) score += 10
|
||||||
|
|
||||||
return Math.min(score, 100)
|
return Math.min(score, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTimeColor(timeRemaining: string): string {
|
||||||
|
if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) return 'text-red-400'
|
||||||
|
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) return 'text-amber-400'
|
||||||
|
return 'text-foreground-muted'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuctionsPage() {
|
export default function AuctionsPage() {
|
||||||
const { isAuthenticated, subscription } = useStore()
|
const { isAuthenticated, subscription } = useStore()
|
||||||
|
|
||||||
@ -148,34 +144,15 @@ export default function AuctionsPage() {
|
|||||||
// Filters
|
// Filters
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [selectedPlatform, setSelectedPlatform] = useState('All')
|
const [selectedPlatform, setSelectedPlatform] = useState('All')
|
||||||
const [maxBid, setMaxBid] = useState<string>('')
|
const [maxBid, setMaxBid] = useState('')
|
||||||
const [filterPreset, setFilterPreset] = useState<FilterPreset>('all')
|
const [filterPreset, setFilterPreset] = useState<FilterPreset>('all')
|
||||||
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
|
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
|
||||||
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
|
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
|
||||||
|
|
||||||
// Check if user is on a paid tier (Trader or Tycoon)
|
|
||||||
const isPaidUser = subscription?.tier === 'trader' || subscription?.tier === 'tycoon'
|
const isPaidUser = subscription?.tier === 'trader' || subscription?.tier === 'tycoon'
|
||||||
|
|
||||||
useEffect(() => {
|
// Data loading
|
||||||
loadData()
|
const loadData = useCallback(async () => {
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAuthenticated && opportunities.length === 0) {
|
|
||||||
loadOpportunities()
|
|
||||||
}
|
|
||||||
}, [isAuthenticated])
|
|
||||||
|
|
||||||
const loadOpportunities = async () => {
|
|
||||||
try {
|
|
||||||
const oppData = await api.getAuctionOpportunities()
|
|
||||||
setOpportunities(oppData.opportunities || [])
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load opportunities:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [auctionsData, hotData, endingData] = await Promise.all([
|
const [auctionsData, hotData, endingData] = await Promise.all([
|
||||||
@ -187,48 +164,40 @@ export default function AuctionsPage() {
|
|||||||
setAllAuctions(auctionsData.auctions || [])
|
setAllAuctions(auctionsData.auctions || [])
|
||||||
setHotAuctions(hotData || [])
|
setHotAuctions(hotData || [])
|
||||||
setEndingSoon(endingData || [])
|
setEndingSoon(endingData || [])
|
||||||
|
|
||||||
if (isAuthenticated) {
|
|
||||||
await loadOpportunities()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load auction data:', error)
|
console.error('Failed to load auction data:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const loadOpportunities = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const oppData = await api.getAuctionOpportunities()
|
||||||
|
setOpportunities(oppData.opportunities || [])
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load opportunities:', e)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && opportunities.length === 0) {
|
||||||
|
loadOpportunities()
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, opportunities.length, loadOpportunities])
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
await loadData()
|
await loadData()
|
||||||
|
if (isAuthenticated) await loadOpportunities()
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
}
|
}, [loadData, loadOpportunities, isAuthenticated])
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const handleTrackDomain = useCallback(async (domain: string) => {
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'USD',
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCurrentAuctions = (): Auction[] => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case 'ending': return endingSoon
|
|
||||||
case 'hot': return hotAuctions
|
|
||||||
case 'opportunities': return opportunities.map(o => o.auction)
|
|
||||||
default: return allAuctions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getOpportunityData = (domain: string) => {
|
|
||||||
if (activeTab !== 'opportunities') return null
|
|
||||||
return opportunities.find(o => o.auction.domain === domain)?.analysis
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track domain to watchlist
|
|
||||||
const handleTrackDomain = async (domain: string) => {
|
|
||||||
if (trackedDomains.has(domain)) return
|
if (trackedDomains.has(domain)) return
|
||||||
|
|
||||||
setTrackingInProgress(domain)
|
setTrackingInProgress(domain)
|
||||||
@ -240,118 +209,269 @@ export default function AuctionsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setTrackingInProgress(null)
|
setTrackingInProgress(null)
|
||||||
}
|
}
|
||||||
}
|
}, [trackedDomains])
|
||||||
|
|
||||||
// Apply filter presets
|
const handleSort = useCallback((field: string) => {
|
||||||
const applyPresetFilter = (auctions: Auction[]): Auction[] => {
|
const f = field as SortField
|
||||||
// Scout users (free tier) see raw feed, Trader+ see filtered feed by default
|
if (sortBy === f) {
|
||||||
const baseFilter = filterPreset === 'all' && isPaidUser ? 'no-trash' : filterPreset
|
setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
switch (baseFilter) {
|
setSortBy(f)
|
||||||
case 'no-trash':
|
setSortDirection('asc')
|
||||||
return auctions.filter(isCleanDomain)
|
}
|
||||||
case 'short':
|
}, [sortBy])
|
||||||
return auctions.filter(a => a.domain.split('.')[0].length <= 4)
|
|
||||||
case 'high-value':
|
// Memoized tabs
|
||||||
return auctions.filter(a =>
|
const tabs = useMemo(() => [
|
||||||
PREMIUM_TLDS.slice(0, 3).includes(a.tld) && // com, io, ai
|
{ id: 'all', label: 'All', icon: Gavel, count: allAuctions.length },
|
||||||
a.num_bids >= 5 &&
|
{ id: 'ending', label: 'Ending Soon', icon: Timer, count: endingSoon.length, color: 'warning' as const },
|
||||||
calculateDealScore(a) >= 70
|
{ id: 'hot', label: 'Hot', icon: Flame, count: hotAuctions.length },
|
||||||
)
|
{ id: 'opportunities', label: 'Opportunities', icon: Target, count: opportunities.length },
|
||||||
case 'low-competition':
|
], [allAuctions.length, endingSoon.length, hotAuctions.length, opportunities.length])
|
||||||
return auctions.filter(a => a.num_bids < 5)
|
|
||||||
default:
|
// Filter and sort auctions
|
||||||
return auctions
|
const sortedAuctions = useMemo(() => {
|
||||||
|
// Get base auctions for current tab
|
||||||
|
let auctions: Auction[] = []
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'ending': auctions = [...endingSoon]; break
|
||||||
|
case 'hot': auctions = [...hotAuctions]; break
|
||||||
|
case 'opportunities': auctions = opportunities.map(o => o.auction); break
|
||||||
|
default: auctions = [...allAuctions]
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const filteredAuctions = useMemo(() => {
|
|
||||||
let auctions = getCurrentAuctions()
|
|
||||||
|
|
||||||
// Apply preset filter
|
// Apply preset filter
|
||||||
auctions = applyPresetFilter(auctions)
|
const baseFilter = filterPreset === 'all' && isPaidUser ? 'no-trash' : filterPreset
|
||||||
|
switch (baseFilter) {
|
||||||
// Apply search query
|
case 'no-trash': auctions = auctions.filter(isCleanDomain); break
|
||||||
if (searchQuery) {
|
case 'short': auctions = auctions.filter(a => a.domain.split('.')[0].length <= 4); break
|
||||||
auctions = auctions.filter(a =>
|
case 'high-value': auctions = auctions.filter(a =>
|
||||||
a.domain.toLowerCase().includes(searchQuery.toLowerCase())
|
PREMIUM_TLDS.slice(0, 3).includes(a.tld) && a.num_bids >= 5 && calculateDealScore(a) >= 70
|
||||||
)
|
); break
|
||||||
|
case 'low-competition': auctions = auctions.filter(a => a.num_bids < 5); break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply search
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase()
|
||||||
|
auctions = auctions.filter(a => a.domain.toLowerCase().includes(q))
|
||||||
|
}
|
||||||
|
|
||||||
// Apply platform filter
|
// Apply platform filter
|
||||||
if (selectedPlatform !== 'All') {
|
if (selectedPlatform !== 'All') {
|
||||||
auctions = auctions.filter(a => a.platform === selectedPlatform)
|
auctions = auctions.filter(a => a.platform === selectedPlatform)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply max bid filter
|
|
||||||
if (maxBid) {
|
|
||||||
auctions = auctions.filter(a => a.current_bid <= parseFloat(maxBid))
|
|
||||||
}
|
|
||||||
|
|
||||||
return auctions
|
|
||||||
}, [activeTab, allAuctions, endingSoon, hotAuctions, opportunities, filterPreset, searchQuery, selectedPlatform, maxBid, isPaidUser])
|
|
||||||
|
|
||||||
const sortedAuctions = activeTab === 'opportunities'
|
// Apply max bid
|
||||||
? filteredAuctions
|
if (maxBid) {
|
||||||
: [...filteredAuctions].sort((a, b) => {
|
const max = parseFloat(maxBid)
|
||||||
const mult = sortDirection === 'asc' ? 1 : -1
|
auctions = auctions.filter(a => a.current_bid <= max)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort (skip for opportunities - already sorted by score)
|
||||||
|
if (activeTab !== 'opportunities') {
|
||||||
|
const mult = sortDirection === 'asc' ? 1 : -1
|
||||||
|
auctions.sort((a, b) => {
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'ending':
|
case 'ending': return mult * (new Date(a.end_time).getTime() - new Date(b.end_time).getTime())
|
||||||
return mult * (new Date(a.end_time).getTime() - new Date(b.end_time).getTime())
|
|
||||||
case 'bid_asc':
|
case 'bid_asc':
|
||||||
case 'bid_desc':
|
case 'bid_desc': return mult * (a.current_bid - b.current_bid)
|
||||||
return mult * (a.current_bid - b.current_bid)
|
case 'bids': return mult * (b.num_bids - a.num_bids)
|
||||||
case 'bids':
|
case 'score': return mult * (calculateDealScore(b) - calculateDealScore(a))
|
||||||
return mult * (b.num_bids - a.num_bids)
|
default: return 0
|
||||||
case 'score':
|
|
||||||
return mult * (calculateDealScore(b) - calculateDealScore(a))
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const getTimeColor = (timeRemaining: string) => {
|
|
||||||
if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) return 'text-red-400'
|
|
||||||
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) return 'text-amber-400'
|
|
||||||
return 'text-foreground-muted'
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSort = (field: SortField) => {
|
|
||||||
if (sortBy === field) {
|
|
||||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
|
||||||
} else {
|
|
||||||
setSortBy(field)
|
|
||||||
setSortDirection('asc')
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic subtitle
|
return auctions
|
||||||
const getSubtitle = () => {
|
}, [activeTab, allAuctions, endingSoon, hotAuctions, opportunities, filterPreset, isPaidUser, searchQuery, selectedPlatform, maxBid, sortBy, sortDirection])
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
|
const subtitle = useMemo(() => {
|
||||||
if (loading) return 'Loading live auctions...'
|
if (loading) return 'Loading live auctions...'
|
||||||
const total = allAuctions.length
|
const total = allAuctions.length
|
||||||
if (total === 0) return 'No active auctions found'
|
if (total === 0) return 'No active auctions found'
|
||||||
const filtered = filteredAuctions.length
|
return `${sortedAuctions.length.toLocaleString()} auctions ${sortedAuctions.length < total ? `(filtered from ${total.toLocaleString()})` : 'across 4 platforms'}`
|
||||||
const filterName = FILTER_PRESETS.find(p => p.id === filterPreset)?.label || 'All'
|
}, [loading, allAuctions.length, sortedAuctions.length])
|
||||||
if (filtered < total && filterPreset !== 'all') {
|
|
||||||
return `${filtered.toLocaleString()} ${filterName} auctions (${total.toLocaleString()} total)`
|
// Get opportunity data helper
|
||||||
}
|
const getOpportunityData = useCallback((domain: string) => {
|
||||||
return `${total.toLocaleString()} live auctions across 4 platforms`
|
if (activeTab !== 'opportunities') return null
|
||||||
}
|
return opportunities.find(o => o.auction.domain === domain)?.analysis
|
||||||
|
}, [activeTab, opportunities])
|
||||||
|
|
||||||
|
// Table columns - memoized
|
||||||
|
const columns = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'domain',
|
||||||
|
header: 'Domain',
|
||||||
|
sortable: true,
|
||||||
|
render: (a: Auction) => (
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={a.affiliate_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-mono font-medium text-foreground hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
{a.domain}
|
||||||
|
</a>
|
||||||
|
<div className="flex items-center gap-2 mt-1 lg:hidden">
|
||||||
|
<PlatformBadge platform={a.platform} />
|
||||||
|
{a.age_years && <span className="text-xs text-foreground-subtle">{a.age_years}y</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'platform',
|
||||||
|
header: 'Platform',
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (a: Auction) => (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<PlatformBadge platform={a.platform} />
|
||||||
|
{a.age_years && (
|
||||||
|
<span className="text-xs text-foreground-subtle flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" /> {a.age_years}y
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bid_asc',
|
||||||
|
header: 'Bid',
|
||||||
|
sortable: true,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (a: Auction) => (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">{formatCurrency(a.current_bid)}</span>
|
||||||
|
{a.buy_now_price && (
|
||||||
|
<p className="text-xs text-accent">Buy: {formatCurrency(a.buy_now_price)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'score',
|
||||||
|
header: 'Deal Score',
|
||||||
|
sortable: true,
|
||||||
|
align: 'center' as const,
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (a: Auction) => {
|
||||||
|
if (activeTab === 'opportunities') {
|
||||||
|
const oppData = getOpportunityData(a.domain)
|
||||||
|
if (oppData) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center justify-center w-9 h-9 bg-accent/10 text-accent font-bold rounded-lg">
|
||||||
|
{oppData.opportunity_score}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPaidUser) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/pricing"
|
||||||
|
className="inline-flex items-center justify-center w-9 h-9 bg-foreground/5 text-foreground-subtle rounded-lg hover:bg-accent/10 hover:text-accent transition-all"
|
||||||
|
title="Upgrade to see Deal Score"
|
||||||
|
>
|
||||||
|
<Crown className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = calculateDealScore(a)
|
||||||
|
return (
|
||||||
|
<div className="inline-flex flex-col items-center">
|
||||||
|
<span className={clsx(
|
||||||
|
"inline-flex items-center justify-center w-9 h-9 rounded-lg font-bold text-sm",
|
||||||
|
score >= 75 ? "bg-accent/20 text-accent" :
|
||||||
|
score >= 50 ? "bg-amber-500/20 text-amber-400" :
|
||||||
|
"bg-foreground/10 text-foreground-muted"
|
||||||
|
)}>
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
{score >= 75 && <span className="text-[10px] text-accent mt-0.5 font-medium">Undervalued</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bids',
|
||||||
|
header: 'Bids',
|
||||||
|
sortable: true,
|
||||||
|
align: 'right' as const,
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (a: Auction) => (
|
||||||
|
<span className={clsx(
|
||||||
|
"font-medium flex items-center justify-end gap-1",
|
||||||
|
a.num_bids >= 20 ? "text-accent" : a.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted"
|
||||||
|
)}>
|
||||||
|
{a.num_bids}
|
||||||
|
{a.num_bids >= 20 && <Flame className="w-3 h-3" />}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ending',
|
||||||
|
header: 'Time Left',
|
||||||
|
sortable: true,
|
||||||
|
align: 'right' as const,
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (a: Auction) => (
|
||||||
|
<span className={clsx("font-medium", getTimeColor(a.time_remaining))}>
|
||||||
|
{a.time_remaining}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (a: Auction) => (
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); handleTrackDomain(a.domain) }}
|
||||||
|
disabled={trackedDomains.has(a.domain) || trackingInProgress === a.domain}
|
||||||
|
className={clsx(
|
||||||
|
"inline-flex items-center justify-center w-8 h-8 rounded-lg transition-all",
|
||||||
|
trackedDomains.has(a.domain)
|
||||||
|
? "bg-accent/20 text-accent cursor-default"
|
||||||
|
: "bg-foreground/5 text-foreground-subtle hover:bg-accent/10 hover:text-accent"
|
||||||
|
)}
|
||||||
|
title={trackedDomains.has(a.domain) ? 'Already tracked' : 'Add to Watchlist'}
|
||||||
|
>
|
||||||
|
{trackingInProgress === a.domain ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : trackedDomains.has(a.domain) ? (
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={a.affiliate_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background text-xs font-medium rounded-lg hover:bg-foreground/90 transition-all"
|
||||||
|
>
|
||||||
|
Bid <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [activeTab, isPaidUser, trackedDomains, trackingInProgress, handleTrackDomain, getOpportunityData])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandCenterLayout
|
<CommandCenterLayout
|
||||||
title="Auctions"
|
title="Auctions"
|
||||||
subtitle={getSubtitle()}
|
subtitle={subtitle}
|
||||||
actions={
|
actions={
|
||||||
<button
|
<ActionButton onClick={handleRefresh} disabled={refreshing} variant="ghost" icon={refreshing ? Loader2 : RefreshCw}>
|
||||||
onClick={handleRefresh}
|
{refreshing ? '' : 'Refresh'}
|
||||||
disabled={refreshing}
|
</ActionButton>
|
||||||
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>
|
<PageContainer>
|
||||||
@ -364,41 +484,14 @@ export default function AuctionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex flex-wrap items-center gap-2 p-1.5 bg-background-secondary/30 border border-border/30 rounded-2xl w-fit">
|
<TabBar tabs={tabs} activeTab={activeTab} onChange={(id) => setActiveTab(id as TabType)} />
|
||||||
{[
|
|
||||||
{ id: 'all' as const, label: 'All', icon: Gavel, count: allAuctions.length },
|
|
||||||
{ id: 'ending' as const, label: 'Ending Soon', icon: Timer, count: endingSoon.length, color: 'warning' },
|
|
||||||
{ id: 'hot' as const, label: 'Hot', icon: Flame, count: hotAuctions.length },
|
|
||||||
{ id: 'opportunities' as const, label: 'Opportunities', icon: Target, count: opportunities.length },
|
|
||||||
].map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={clsx(
|
|
||||||
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-xl transition-all",
|
|
||||||
activeTab === tab.id
|
|
||||||
? tab.color === 'warning'
|
|
||||||
? "bg-amber-500 text-background"
|
|
||||||
: "bg-gradient-to-r from-accent to-accent/80 text-background shadow-lg shadow-accent/20"
|
|
||||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<tab.icon className="w-4 h-4" />
|
|
||||||
<span className="hidden sm:inline">{tab.label}</span>
|
|
||||||
<span className={clsx(
|
|
||||||
"text-xs px-1.5 py-0.5 rounded",
|
|
||||||
activeTab === tab.id ? "bg-background/20" : "bg-foreground/10"
|
|
||||||
)}>{tab.count}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Smart Filter Presets (from GAP_ANALYSIS.md) */}
|
{/* Smart Filter Presets */}
|
||||||
<div className="flex flex-wrap gap-2 p-1.5 bg-background-secondary/30 border border-border/30 rounded-2xl">
|
<div className="flex flex-wrap gap-2 p-1.5 bg-background-secondary/30 border border-border/30 rounded-xl">
|
||||||
{FILTER_PRESETS.map((preset) => {
|
{FILTER_PRESETS.map((preset) => {
|
||||||
const isDisabled = preset.proOnly && !isPaidUser
|
const isDisabled = preset.proOnly && !isPaidUser
|
||||||
const isActive = filterPreset === preset.id
|
const isActive = filterPreset === preset.id
|
||||||
|
const Icon = preset.icon
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
@ -406,19 +499,17 @@ export default function AuctionsPage() {
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
title={isDisabled ? 'Upgrade to Trader to use this filter' : preset.description}
|
title={isDisabled ? 'Upgrade to Trader to use this filter' : preset.description}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-xl transition-all",
|
"flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg transition-all",
|
||||||
isActive
|
isActive
|
||||||
? "bg-gradient-to-r from-accent to-accent/80 text-background shadow-lg shadow-accent/20"
|
? "bg-accent text-background shadow-md"
|
||||||
: isDisabled
|
: isDisabled
|
||||||
? "text-foreground-subtle opacity-50 cursor-not-allowed"
|
? "text-foreground-subtle opacity-50 cursor-not-allowed"
|
||||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<preset.icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">{preset.label}</span>
|
<span className="hidden sm:inline">{preset.label}</span>
|
||||||
{preset.proOnly && !isPaidUser && (
|
{preset.proOnly && !isPaidUser && <Crown className="w-3 h-3 text-amber-400" />}
|
||||||
<Crown className="w-3 h-3 text-amber-400" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -446,45 +537,27 @@ export default function AuctionsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap gap-3">
|
<FilterBar>
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
<SearchInput
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
value={searchQuery}
|
||||||
<input
|
onChange={setSearchQuery}
|
||||||
type="text"
|
placeholder="Search domains..."
|
||||||
placeholder="Search domains..."
|
className="flex-1 min-w-[200px] max-w-md"
|
||||||
value={searchQuery}
|
/>
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
<SelectDropdown value={selectedPlatform} onChange={setSelectedPlatform} options={PLATFORMS} />
|
||||||
className="w-full pl-11 pr-10 py-3 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-4 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground">
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={selectedPlatform}
|
|
||||||
onChange={(e) => setSelectedPlatform(e.target.value)}
|
|
||||||
className="px-4 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
|
|
||||||
text-sm text-foreground cursor-pointer focus:outline-none focus:border-accent/50"
|
|
||||||
>
|
|
||||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
<DollarSign className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Max bid"
|
placeholder="Max bid"
|
||||||
value={maxBid}
|
value={maxBid}
|
||||||
onChange={(e) => setMaxBid(e.target.value)}
|
onChange={(e) => setMaxBid(e.target.value)}
|
||||||
className="w-32 pl-10 pr-4 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
|
className="w-28 h-10 pl-9 pr-3 bg-background-secondary/50 border border-border/40 rounded-xl
|
||||||
text-sm text-foreground placeholder:text-foreground-subtle
|
text-sm text-foreground placeholder:text-foreground-subtle
|
||||||
focus:outline-none focus:border-accent/50"
|
focus:outline-none focus:border-accent/50 transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FilterBar>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<PremiumTable
|
<PremiumTable
|
||||||
@ -493,184 +566,11 @@ export default function AuctionsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
onSort={(key) => handleSort(key as SortField)}
|
onSort={handleSort}
|
||||||
emptyIcon={<Gavel className="w-12 h-12 text-foreground-subtle" />}
|
emptyIcon={<Gavel className="w-12 h-12 text-foreground-subtle" />}
|
||||||
emptyTitle={searchQuery ? `No auctions matching "${searchQuery}"` : "No auctions found"}
|
emptyTitle={searchQuery ? `No auctions matching "${searchQuery}"` : "No auctions found"}
|
||||||
emptyDescription="Try adjusting your filters or check back later"
|
emptyDescription="Try adjusting your filters or check back later"
|
||||||
columns={[
|
columns={columns}
|
||||||
{
|
|
||||||
key: 'domain',
|
|
||||||
header: 'Domain',
|
|
||||||
sortable: true,
|
|
||||||
render: (a) => (
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href={a.affiliate_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-mono font-medium text-foreground hover:text-accent transition-colors"
|
|
||||||
>
|
|
||||||
{a.domain}
|
|
||||||
</a>
|
|
||||||
<div className="flex items-center gap-2 mt-1 lg:hidden">
|
|
||||||
<PlatformBadge platform={a.platform} />
|
|
||||||
{a.age_years && <span className="text-xs text-foreground-subtle">{a.age_years}y</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'platform',
|
|
||||||
header: 'Platform',
|
|
||||||
hideOnMobile: true,
|
|
||||||
render: (a) => (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<PlatformBadge platform={a.platform} />
|
|
||||||
{a.age_years && (
|
|
||||||
<span className="text-xs text-foreground-subtle flex items-center gap-1">
|
|
||||||
<Clock className="w-3 h-3" /> {a.age_years}y
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bid_asc',
|
|
||||||
header: 'Bid',
|
|
||||||
sortable: true,
|
|
||||||
align: 'right',
|
|
||||||
render: (a) => (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-foreground">{formatCurrency(a.current_bid)}</span>
|
|
||||||
{a.buy_now_price && (
|
|
||||||
<p className="text-xs text-accent">Buy: {formatCurrency(a.buy_now_price)}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
// Deal Score column - visible for Trader+ users
|
|
||||||
{
|
|
||||||
key: 'score',
|
|
||||||
header: 'Deal Score',
|
|
||||||
sortable: true,
|
|
||||||
align: 'center',
|
|
||||||
hideOnMobile: true,
|
|
||||||
render: (a) => {
|
|
||||||
// For opportunities tab, show opportunity score
|
|
||||||
if (activeTab === 'opportunities') {
|
|
||||||
const oppData = getOpportunityData(a.domain)
|
|
||||||
if (oppData) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center justify-center w-9 h-9 bg-accent/10 text-accent font-bold rounded-lg">
|
|
||||||
{oppData.opportunity_score}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other tabs, show calculated deal score (Trader+ only)
|
|
||||||
if (!isPaidUser) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href="/pricing"
|
|
||||||
className="inline-flex items-center justify-center w-9 h-9 bg-foreground/5 text-foreground-subtle rounded-lg
|
|
||||||
hover:bg-accent/10 hover:text-accent transition-all"
|
|
||||||
title="Upgrade to see Deal Score"
|
|
||||||
>
|
|
||||||
<Crown className="w-4 h-4" />
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const score = calculateDealScore(a)
|
|
||||||
return (
|
|
||||||
<div className="inline-flex flex-col items-center">
|
|
||||||
<span className={clsx(
|
|
||||||
"inline-flex items-center justify-center w-9 h-9 rounded-lg font-bold text-sm",
|
|
||||||
score >= 75 ? "bg-accent/20 text-accent" :
|
|
||||||
score >= 50 ? "bg-amber-500/20 text-amber-400" :
|
|
||||||
"bg-foreground/10 text-foreground-muted"
|
|
||||||
)}>
|
|
||||||
{score}
|
|
||||||
</span>
|
|
||||||
{score >= 75 && (
|
|
||||||
<span className="text-[10px] text-accent mt-0.5 font-medium">Undervalued</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bids',
|
|
||||||
header: 'Bids',
|
|
||||||
sortable: true,
|
|
||||||
align: 'right',
|
|
||||||
hideOnMobile: true,
|
|
||||||
render: (a) => (
|
|
||||||
<span className={clsx(
|
|
||||||
"font-medium flex items-center justify-end gap-1",
|
|
||||||
a.num_bids >= 20 ? "text-accent" : a.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted"
|
|
||||||
)}>
|
|
||||||
{a.num_bids}
|
|
||||||
{a.num_bids >= 20 && <Flame className="w-3 h-3" />}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ending',
|
|
||||||
header: 'Time Left',
|
|
||||||
sortable: true,
|
|
||||||
align: 'right',
|
|
||||||
hideOnMobile: true,
|
|
||||||
render: (a) => (
|
|
||||||
<span className={clsx("font-medium", getTimeColor(a.time_remaining))}>
|
|
||||||
{a.time_remaining}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
align: 'right',
|
|
||||||
render: (a) => (
|
|
||||||
<div className="flex items-center gap-2 justify-end">
|
|
||||||
{/* Track Button */}
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
handleTrackDomain(a.domain)
|
|
||||||
}}
|
|
||||||
disabled={trackedDomains.has(a.domain) || trackingInProgress === a.domain}
|
|
||||||
className={clsx(
|
|
||||||
"inline-flex items-center justify-center w-8 h-8 rounded-lg transition-all",
|
|
||||||
trackedDomains.has(a.domain)
|
|
||||||
? "bg-accent/20 text-accent cursor-default"
|
|
||||||
: "bg-foreground/5 text-foreground-subtle hover:bg-accent/10 hover:text-accent"
|
|
||||||
)}
|
|
||||||
title={trackedDomains.has(a.domain) ? 'Already tracked' : 'Add to Watchlist'}
|
|
||||||
>
|
|
||||||
{trackingInProgress === a.domain ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : trackedDomains.has(a.domain) ? (
|
|
||||||
<Check className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{/* Bid Button */}
|
|
||||||
<a
|
|
||||||
href={a.affiliate_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background text-xs font-medium rounded-lg
|
|
||||||
hover:bg-foreground/90 transition-all opacity-70 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
Bid <ExternalLink className="w-3 h-3" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</CommandCenterLayout>
|
</CommandCenterLayout>
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
import { PremiumTable, StatCard, PageContainer, Badge, SectionHeader } from '@/components/PremiumTable'
|
import { PremiumTable, StatCard, PageContainer, Badge, SectionHeader, SearchInput, ActionButton } from '@/components/PremiumTable'
|
||||||
import { Toast, useToast } from '@/components/Toast'
|
import { Toast, useToast } from '@/components/Toast'
|
||||||
import {
|
import {
|
||||||
Eye,
|
Eye,
|
||||||
@ -13,15 +13,14 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Gavel,
|
Gavel,
|
||||||
Clock,
|
Clock,
|
||||||
Bell,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Search,
|
|
||||||
Plus,
|
Plus,
|
||||||
Zap,
|
Zap,
|
||||||
Crown,
|
Crown,
|
||||||
Activity,
|
Activity,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@ -67,14 +66,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
// Load dashboard data
|
const loadDashboardData = useCallback(async () => {
|
||||||
useEffect(() => {
|
|
||||||
if (isAuthenticated) {
|
|
||||||
loadDashboardData()
|
|
||||||
}
|
|
||||||
}, [isAuthenticated])
|
|
||||||
|
|
||||||
const loadDashboardData = async () => {
|
|
||||||
try {
|
try {
|
||||||
const [auctions, trending] = await Promise.all([
|
const [auctions, trending] = await Promise.all([
|
||||||
api.getEndingSoonAuctions(5).catch(() => []),
|
api.getEndingSoonAuctions(5).catch(() => []),
|
||||||
@ -88,9 +80,16 @@ export default function DashboardPage() {
|
|||||||
setLoadingAuctions(false)
|
setLoadingAuctions(false)
|
||||||
setLoadingTlds(false)
|
setLoadingTlds(false)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleQuickAdd = async (e: React.FormEvent) => {
|
// Load dashboard data
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
loadDashboardData()
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, loadDashboardData])
|
||||||
|
|
||||||
|
const handleQuickAdd = useCallback(async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!quickDomain.trim()) return
|
if (!quickDomain.trim()) return
|
||||||
|
|
||||||
@ -105,7 +104,29 @@ export default function DashboardPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setAddingDomain(false)
|
setAddingDomain(false)
|
||||||
}
|
}
|
||||||
}
|
}, [quickDomain, showToast])
|
||||||
|
|
||||||
|
// Memoized computed values
|
||||||
|
const { availableDomains, totalDomains, tierName, TierIcon, greeting, subtitle } = useMemo(() => {
|
||||||
|
const availableDomains = domains?.filter(d => d.is_available) || []
|
||||||
|
const totalDomains = domains?.length || 0
|
||||||
|
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||||
|
const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap
|
||||||
|
|
||||||
|
const hour = new Date().getHours()
|
||||||
|
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'
|
||||||
|
|
||||||
|
let subtitle = ''
|
||||||
|
if (availableDomains.length > 0) {
|
||||||
|
subtitle = `${availableDomains.length} domain${availableDomains.length !== 1 ? 's' : ''} ready to pounce!`
|
||||||
|
} else if (totalDomains > 0) {
|
||||||
|
subtitle = `Monitoring ${totalDomains} domain${totalDomains !== 1 ? 's' : ''} for you`
|
||||||
|
} else {
|
||||||
|
subtitle = 'Start tracking domains to find opportunities'
|
||||||
|
}
|
||||||
|
|
||||||
|
return { availableDomains, totalDomains, tierName, TierIcon, greeting, subtitle }
|
||||||
|
}, [domains, subscription])
|
||||||
|
|
||||||
if (isLoading || !isAuthenticated) {
|
if (isLoading || !isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
@ -115,35 +136,10 @@ export default function DashboardPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate stats
|
|
||||||
const availableDomains = domains?.filter(d => d.is_available) || []
|
|
||||||
const totalDomains = domains?.length || 0
|
|
||||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
|
||||||
const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap
|
|
||||||
|
|
||||||
// Dynamic greeting based on time
|
|
||||||
const getGreeting = () => {
|
|
||||||
const hour = new Date().getHours()
|
|
||||||
if (hour < 12) return 'Good morning'
|
|
||||||
if (hour < 18) return 'Good afternoon'
|
|
||||||
return 'Good evening'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic subtitle
|
|
||||||
const getSubtitle = () => {
|
|
||||||
if (availableDomains.length > 0) {
|
|
||||||
return `🎯 ${availableDomains.length} domain${availableDomains.length !== 1 ? 's' : ''} ready to pounce!`
|
|
||||||
}
|
|
||||||
if (totalDomains > 0) {
|
|
||||||
return `Monitoring ${totalDomains} domain${totalDomains !== 1 ? 's' : ''} for you`
|
|
||||||
}
|
|
||||||
return 'Start tracking domains to find opportunities'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandCenterLayout
|
<CommandCenterLayout
|
||||||
title={`${getGreeting()}${user?.name ? `, ${user.name.split(' ')[0]}` : ''}`}
|
title={`${greeting}${user?.name ? `, ${user.name.split(' ')[0]}` : ''}`}
|
||||||
subtitle={getSubtitle()}
|
subtitle={subtitle}
|
||||||
>
|
>
|
||||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
import { PageContainer, StatCard, Badge } from '@/components/PremiumTable'
|
import { PageContainer, StatCard, Badge, ActionButton } from '@/components/PremiumTable'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Shield,
|
Shield,
|
||||||
@ -20,7 +20,6 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
X,
|
X,
|
||||||
Sparkles,
|
|
||||||
Tag,
|
Tag,
|
||||||
Store,
|
Store,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@ -88,19 +87,7 @@ export default function MyListingsPage() {
|
|||||||
allow_offers: true,
|
allow_offers: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
const loadListings = useCallback(async () => {
|
||||||
loadListings()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Auto-open create modal if domain is prefilled from portfolio
|
|
||||||
useEffect(() => {
|
|
||||||
if (prefillDomain) {
|
|
||||||
setNewListing(prev => ({ ...prev, domain: prefillDomain }))
|
|
||||||
setShowCreateModal(true)
|
|
||||||
}
|
|
||||||
}, [prefillDomain])
|
|
||||||
|
|
||||||
const loadListings = async () => {
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await api.request<Listing[]>('/listings/my')
|
const data = await api.request<Listing[]>('/listings/my')
|
||||||
@ -110,7 +97,19 @@ export default function MyListingsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadListings()
|
||||||
|
}, [loadListings])
|
||||||
|
|
||||||
|
// Auto-open create modal if domain is prefilled from portfolio
|
||||||
|
useEffect(() => {
|
||||||
|
if (prefillDomain) {
|
||||||
|
setNewListing(prev => ({ ...prev, domain: prefillDomain }))
|
||||||
|
setShowCreateModal(true)
|
||||||
|
}
|
||||||
|
}, [prefillDomain])
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@ -1,9 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
import { PageContainer, StatCard, Badge } from '@/components/PremiumTable'
|
import {
|
||||||
|
PageContainer,
|
||||||
|
StatCard,
|
||||||
|
Badge,
|
||||||
|
SearchInput,
|
||||||
|
FilterBar,
|
||||||
|
SelectDropdown,
|
||||||
|
ActionButton,
|
||||||
|
} from '@/components/PremiumTable'
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Shield,
|
Shield,
|
||||||
@ -13,8 +21,6 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Filter,
|
Filter,
|
||||||
SortAsc,
|
|
||||||
ArrowUpDown,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@ -47,11 +53,7 @@ export default function CommandMarketplacePage() {
|
|||||||
const [sortBy, setSortBy] = useState<SortOption>('newest')
|
const [sortBy, setSortBy] = useState<SortOption>('newest')
|
||||||
const [showFilters, setShowFilters] = useState(false)
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
const loadListings = useCallback(async () => {
|
||||||
loadListings()
|
|
||||||
}, [sortBy, verifiedOnly])
|
|
||||||
|
|
||||||
const loadListings = async () => {
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
@ -67,7 +69,11 @@ export default function CommandMarketplacePage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [sortBy, verifiedOnly])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadListings()
|
||||||
|
}, [loadListings])
|
||||||
|
|
||||||
const formatPrice = (price: number | null, currency: string) => {
|
const formatPrice = (price: number | null, currency: string) => {
|
||||||
if (!price) return 'Make Offer'
|
if (!price) return 'Make Offer'
|
||||||
@ -78,57 +84,42 @@ export default function CommandMarketplacePage() {
|
|||||||
}).format(price)
|
}).format(price)
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredListings = listings.filter(listing => {
|
// Memoized filtered and sorted listings
|
||||||
// Search filter
|
const sortedListings = useMemo(() => {
|
||||||
if (searchQuery) {
|
let result = listings.filter(listing => {
|
||||||
const query = searchQuery.toLowerCase()
|
if (searchQuery && !listing.domain.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
||||||
if (!listing.domain.toLowerCase().includes(query)) {
|
if (minPrice && listing.asking_price && listing.asking_price < parseFloat(minPrice)) return false
|
||||||
return false
|
if (maxPrice && listing.asking_price && listing.asking_price > parseFloat(maxPrice)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'price_asc': return (a.asking_price || 0) - (b.asking_price || 0)
|
||||||
|
case 'price_desc': return (b.asking_price || 0) - (a.asking_price || 0)
|
||||||
|
case 'score': return (b.pounce_score || 0) - (a.pounce_score || 0)
|
||||||
|
default: return 0
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
}, [listings, searchQuery, minPrice, maxPrice, sortBy])
|
||||||
// Price filters
|
|
||||||
if (minPrice && listing.asking_price && listing.asking_price < parseFloat(minPrice)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (maxPrice && listing.asking_price && listing.asking_price > parseFloat(maxPrice)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sort listings
|
// Memoized stats
|
||||||
const sortedListings = [...filteredListings].sort((a, b) => {
|
const stats = useMemo(() => {
|
||||||
switch (sortBy) {
|
const verifiedCount = listings.filter(l => l.is_verified).length
|
||||||
case 'price_asc':
|
const pricesWithValue = listings.filter(l => l.asking_price)
|
||||||
return (a.asking_price || 0) - (b.asking_price || 0)
|
const avgPrice = pricesWithValue.length > 0
|
||||||
case 'price_desc':
|
? pricesWithValue.reduce((sum, l) => sum + (l.asking_price || 0), 0) / pricesWithValue.length
|
||||||
return (b.asking_price || 0) - (a.asking_price || 0)
|
: 0
|
||||||
case 'score':
|
return { verifiedCount, avgPrice }
|
||||||
return (b.pounce_score || 0) - (a.pounce_score || 0)
|
}, [listings])
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const verifiedCount = listings.filter(l => l.is_verified).length
|
|
||||||
const avgPrice = listings.length > 0
|
|
||||||
? listings.filter(l => l.asking_price).reduce((sum, l) => sum + (l.asking_price || 0), 0) / listings.filter(l => l.asking_price).length
|
|
||||||
: 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandCenterLayout
|
<CommandCenterLayout
|
||||||
title="Marketplace"
|
title="Marketplace"
|
||||||
subtitle={`${listings.length} premium domains for sale`}
|
subtitle={`${listings.length} premium domains for sale`}
|
||||||
actions={
|
actions={
|
||||||
<Link
|
<Link href="/command/listings">
|
||||||
href="/command/listings"
|
<ActionButton icon={Tag} variant="secondary">My Listings</ActionButton>
|
||||||
className="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"
|
|
||||||
>
|
|
||||||
<Tag className="w-4 h-4" />
|
|
||||||
My Listings
|
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -136,10 +127,10 @@ export default function CommandMarketplacePage() {
|
|||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard title="Total Listings" value={listings.length} icon={Store} />
|
<StatCard title="Total Listings" value={listings.length} icon={Store} />
|
||||||
<StatCard title="Verified Sellers" value={verifiedCount} icon={Shield} />
|
<StatCard title="Verified Sellers" value={stats.verifiedCount} icon={Shield} />
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Avg. Price"
|
title="Avg. Price"
|
||||||
value={avgPrice > 0 ? `$${Math.round(avgPrice).toLocaleString()}` : '—'}
|
value={stats.avgPrice > 0 ? `$${Math.round(stats.avgPrice).toLocaleString()}` : '—'}
|
||||||
icon={DollarSign}
|
icon={DollarSign}
|
||||||
/>
|
/>
|
||||||
<StatCard title="Results" value={sortedListings.length} icon={Search} />
|
<StatCard title="Results" value={sortedListings.length} icon={Search} />
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api, PortfolioDomain, PortfolioSummary, DomainValuation, DomainHealthReport, HealthStatus } from '@/lib/api'
|
import { api, PortfolioDomain, PortfolioSummary, DomainValuation, DomainHealthReport, HealthStatus } from '@/lib/api'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
import { PremiumTable, StatCard, PageContainer } from '@/components/PremiumTable'
|
import { PremiumTable, StatCard, PageContainer, ActionButton } from '@/components/PremiumTable'
|
||||||
import { Toast, useToast } from '@/components/Toast'
|
import { Toast, useToast } from '@/components/Toast'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@ -25,6 +25,8 @@ import {
|
|||||||
MoreVertical,
|
MoreVertical,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
// Health status configuration
|
// Health status configuration
|
||||||
const healthStatusConfig: Record<HealthStatus, {
|
const healthStatusConfig: Record<HealthStatus, {
|
||||||
@ -39,8 +41,6 @@ const healthStatusConfig: Record<HealthStatus, {
|
|||||||
critical: { label: 'Critical', color: 'text-red-400', bgColor: 'bg-red-400/10', icon: AlertTriangle },
|
critical: { label: 'Critical', color: 'text-red-400', bgColor: 'bg-red-400/10', icon: AlertTriangle },
|
||||||
unknown: { label: 'Unknown', color: 'text-foreground-muted', bgColor: 'bg-foreground/5', icon: Activity },
|
unknown: { label: 'Unknown', color: 'text-foreground-muted', bgColor: 'bg-foreground/5', icon: Activity },
|
||||||
}
|
}
|
||||||
import clsx from 'clsx'
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
export default function PortfolioPage() {
|
export default function PortfolioPage() {
|
||||||
const { subscription } = useStore()
|
const { subscription } = useStore()
|
||||||
@ -93,11 +93,7 @@ export default function PortfolioPage() {
|
|||||||
sale_price: '',
|
sale_price: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
const loadPortfolio = useCallback(async () => {
|
||||||
loadPortfolio()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadPortfolio = async () => {
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [portfolioData, summaryData] = await Promise.all([
|
const [portfolioData, summaryData] = await Promise.all([
|
||||||
@ -111,9 +107,13 @@ export default function PortfolioPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleAddDomain = async (e: React.FormEvent) => {
|
useEffect(() => {
|
||||||
|
loadPortfolio()
|
||||||
|
}, [loadPortfolio])
|
||||||
|
|
||||||
|
const handleAddDomain = useCallback(async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!addForm.domain.trim()) return
|
if (!addForm.domain.trim()) return
|
||||||
|
|
||||||
@ -137,9 +137,9 @@ export default function PortfolioPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setAddingDomain(false)
|
setAddingDomain(false)
|
||||||
}
|
}
|
||||||
}
|
}, [addForm, loadPortfolio, showToast])
|
||||||
|
|
||||||
const handleEditDomain = async (e: React.FormEvent) => {
|
const handleEditDomain = useCallback(async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!selectedDomain) return
|
if (!selectedDomain) return
|
||||||
|
|
||||||
@ -161,9 +161,9 @@ export default function PortfolioPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSavingEdit(false)
|
setSavingEdit(false)
|
||||||
}
|
}
|
||||||
}
|
}, [selectedDomain, editForm, loadPortfolio, showToast])
|
||||||
|
|
||||||
const handleSellDomain = async (e: React.FormEvent) => {
|
const handleSellDomain = useCallback(async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!selectedDomain || !sellForm.sale_price) return
|
if (!selectedDomain || !sellForm.sale_price) return
|
||||||
|
|
||||||
@ -178,9 +178,9 @@ export default function PortfolioPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setProcessingSale(false)
|
setProcessingSale(false)
|
||||||
}
|
}
|
||||||
}
|
}, [selectedDomain, sellForm, loadPortfolio, showToast])
|
||||||
|
|
||||||
const handleValuate = async (domain: PortfolioDomain) => {
|
const handleValuate = useCallback(async (domain: PortfolioDomain) => {
|
||||||
setValuatingDomain(domain.domain)
|
setValuatingDomain(domain.domain)
|
||||||
setShowValuationModal(true)
|
setShowValuationModal(true)
|
||||||
try {
|
try {
|
||||||
@ -192,9 +192,9 @@ export default function PortfolioPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setValuatingDomain('')
|
setValuatingDomain('')
|
||||||
}
|
}
|
||||||
}
|
}, [showToast])
|
||||||
|
|
||||||
const handleRefresh = async (domain: PortfolioDomain) => {
|
const handleRefresh = useCallback(async (domain: PortfolioDomain) => {
|
||||||
setRefreshingId(domain.id)
|
setRefreshingId(domain.id)
|
||||||
try {
|
try {
|
||||||
await api.refreshDomainValue(domain.id)
|
await api.refreshDomainValue(domain.id)
|
||||||
@ -205,9 +205,9 @@ export default function PortfolioPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setRefreshingId(null)
|
setRefreshingId(null)
|
||||||
}
|
}
|
||||||
}
|
}, [loadPortfolio, showToast])
|
||||||
|
|
||||||
const handleHealthCheck = async (domainName: string) => {
|
const handleHealthCheck = useCallback(async (domainName: string) => {
|
||||||
if (loadingHealth[domainName]) return
|
if (loadingHealth[domainName]) return
|
||||||
|
|
||||||
setLoadingHealth(prev => ({ ...prev, [domainName]: true }))
|
setLoadingHealth(prev => ({ ...prev, [domainName]: true }))
|
||||||
@ -220,9 +220,9 @@ export default function PortfolioPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingHealth(prev => ({ ...prev, [domainName]: false }))
|
setLoadingHealth(prev => ({ ...prev, [domainName]: false }))
|
||||||
}
|
}
|
||||||
}
|
}, [loadingHealth, showToast])
|
||||||
|
|
||||||
const handleDelete = async (domain: PortfolioDomain) => {
|
const handleDelete = useCallback(async (domain: PortfolioDomain) => {
|
||||||
if (!confirm(`Remove ${domain.domain} from your portfolio?`)) return
|
if (!confirm(`Remove ${domain.domain} from your portfolio?`)) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -232,9 +232,9 @@ export default function PortfolioPage() {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast(err.message || 'Failed to remove', 'error')
|
showToast(err.message || 'Failed to remove', 'error')
|
||||||
}
|
}
|
||||||
}
|
}, [loadPortfolio, showToast])
|
||||||
|
|
||||||
const openEditModal = (domain: PortfolioDomain) => {
|
const openEditModal = useCallback((domain: PortfolioDomain) => {
|
||||||
setSelectedDomain(domain)
|
setSelectedDomain(domain)
|
||||||
setEditForm({
|
setEditForm({
|
||||||
purchase_price: domain.purchase_price?.toString() || '',
|
purchase_price: domain.purchase_price?.toString() || '',
|
||||||
@ -245,50 +245,45 @@ export default function PortfolioPage() {
|
|||||||
notes: domain.notes || '',
|
notes: domain.notes || '',
|
||||||
})
|
})
|
||||||
setShowEditModal(true)
|
setShowEditModal(true)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const openSellModal = (domain: PortfolioDomain) => {
|
const openSellModal = useCallback((domain: PortfolioDomain) => {
|
||||||
setSelectedDomain(domain)
|
setSelectedDomain(domain)
|
||||||
setSellForm({
|
setSellForm({
|
||||||
sale_date: new Date().toISOString().split('T')[0],
|
sale_date: new Date().toISOString().split('T')[0],
|
||||||
sale_price: '',
|
sale_price: '',
|
||||||
})
|
})
|
||||||
setShowSellModal(true)
|
setShowSellModal(true)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const portfolioLimit = subscription?.portfolio_limit || 0
|
const portfolioLimit = subscription?.portfolio_limit || 0
|
||||||
const canAddMore = portfolioLimit === -1 || portfolio.length < portfolioLimit
|
const canAddMore = portfolioLimit === -1 || portfolio.length < portfolioLimit
|
||||||
|
|
||||||
// Dynamic subtitle
|
// Memoized stats and subtitle
|
||||||
const getSubtitle = () => {
|
const { expiringSoonCount, subtitle } = useMemo(() => {
|
||||||
if (loading) return 'Loading your portfolio...'
|
const expiring = portfolio.filter(d => {
|
||||||
if (portfolio.length === 0) return 'Start tracking your domains'
|
|
||||||
const expiringSoon = portfolio.filter(d => {
|
|
||||||
if (!d.renewal_date) return false
|
if (!d.renewal_date) return false
|
||||||
const days = Math.ceil((new Date(d.renewal_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
const days = Math.ceil((new Date(d.renewal_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||||
return days <= 30 && days > 0
|
return days <= 30 && days > 0
|
||||||
}).length
|
}).length
|
||||||
if (expiringSoon > 0) {
|
|
||||||
return `${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''} • ${expiringSoon} expiring soon`
|
let sub = ''
|
||||||
}
|
if (loading) sub = 'Loading your portfolio...'
|
||||||
return `Managing ${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''}`
|
else if (portfolio.length === 0) sub = 'Start tracking your domains'
|
||||||
}
|
else if (expiring > 0) sub = `${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''} • ${expiring} expiring soon`
|
||||||
|
else sub = `Managing ${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''}`
|
||||||
|
|
||||||
|
return { expiringSoonCount: expiring, subtitle: sub }
|
||||||
|
}, [portfolio, loading])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandCenterLayout
|
<CommandCenterLayout
|
||||||
title="Portfolio"
|
title="Portfolio"
|
||||||
subtitle={getSubtitle()}
|
subtitle={subtitle}
|
||||||
actions={
|
actions={
|
||||||
<button
|
<ActionButton onClick={() => setShowAddModal(true)} disabled={!canAddMore} icon={Plus}>
|
||||||
onClick={() => setShowAddModal(true)}
|
Add Domain
|
||||||
disabled={!canAddMore}
|
</ActionButton>
|
||||||
className="flex items-center gap-2 h-9 px-4 bg-gradient-to-r from-accent to-accent/80 text-background
|
|
||||||
rounded-lg font-medium text-sm hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.4)]
|
|
||||||
transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span className="hidden sm:inline">Add Domain</span>
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||||
@ -297,15 +292,7 @@ export default function PortfolioPage() {
|
|||||||
{/* Summary Stats - Only reliable data */}
|
{/* Summary Stats - Only reliable data */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard title="Total Domains" value={summary?.total_domains || 0} icon={Briefcase} />
|
<StatCard title="Total Domains" value={summary?.total_domains || 0} icon={Briefcase} />
|
||||||
<StatCard
|
<StatCard title="Expiring Soon" value={expiringSoonCount} icon={Calendar} />
|
||||||
title="Expiring Soon"
|
|
||||||
value={portfolio.filter(d => {
|
|
||||||
if (!d.renewal_date) return false
|
|
||||||
const days = Math.ceil((new Date(d.renewal_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
|
||||||
return days <= 30 && days > 0
|
|
||||||
}).length}
|
|
||||||
icon={Calendar}
|
|
||||||
/>
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Need Attention"
|
title="Need Attention"
|
||||||
value={Object.values(healthReports).filter(r => r.status !== 'healthy').length}
|
value={Object.values(healthReports).filter(r => r.status !== 'healthy').length}
|
||||||
|
|||||||
@ -1,30 +1,32 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
import { PremiumTable, Badge, StatCard, PageContainer, SectionHeader } from '@/components/PremiumTable'
|
import {
|
||||||
|
PremiumTable,
|
||||||
|
StatCard,
|
||||||
|
PageContainer,
|
||||||
|
SearchInput,
|
||||||
|
TabBar,
|
||||||
|
FilterBar,
|
||||||
|
SelectDropdown,
|
||||||
|
ActionButton,
|
||||||
|
} from '@/components/PremiumTable'
|
||||||
import {
|
import {
|
||||||
Search,
|
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
|
||||||
Minus,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Globe,
|
Globe,
|
||||||
ArrowUpDown,
|
|
||||||
DollarSign,
|
DollarSign,
|
||||||
BarChart3,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
X,
|
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Shield,
|
|
||||||
Zap,
|
|
||||||
Cpu,
|
Cpu,
|
||||||
MapPin,
|
MapPin,
|
||||||
Coins,
|
Coins,
|
||||||
Crown,
|
Crown,
|
||||||
Info,
|
Info,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
@ -43,63 +45,67 @@ interface TLDData {
|
|||||||
risk_level: 'low' | 'medium' | 'high'
|
risk_level: 'low' | 'medium' | 'high'
|
||||||
risk_reason: string
|
risk_reason: string
|
||||||
popularity_rank?: number
|
popularity_rank?: number
|
||||||
type?: string // generic, ccTLD, new
|
type?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category definitions for filtering
|
// Category definitions
|
||||||
const CATEGORIES = {
|
const CATEGORIES = [
|
||||||
all: { label: 'All', icon: Globe, filter: () => true },
|
{ id: 'all', label: 'All', icon: Globe },
|
||||||
tech: { label: 'Tech', icon: Cpu, filter: (tld: TLDData) => ['ai', 'io', 'app', 'dev', 'tech', 'code', 'cloud', 'data', 'api', 'software'].includes(tld.tld) },
|
{ id: 'tech', label: 'Tech', icon: Cpu },
|
||||||
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) },
|
{ id: 'geo', label: 'Geo', icon: MapPin },
|
||||||
budget: { label: 'Budget', icon: Coins, filter: (tld: TLDData) => tld.min_price < 5 },
|
{ id: 'budget', label: 'Budget', icon: Coins },
|
||||||
premium: { label: 'Premium', icon: Crown, filter: (tld: TLDData) => tld.min_price >= 50 },
|
{ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategoryKey = keyof typeof CATEGORIES
|
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' },
|
||||||
|
]
|
||||||
|
|
||||||
// Risk level now comes from backend, but keep helper for UI
|
// Memoized Sparkline
|
||||||
function getRiskInfo(tld: TLDData): { level: 'low' | 'medium' | 'high', reason: string } {
|
const Sparkline = memo(function Sparkline({ trend }: { trend: number }) {
|
||||||
return {
|
|
||||||
level: tld.risk_level || 'low',
|
|
||||||
reason: tld.risk_reason || 'Stable'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sparkline component for mini trend visualization
|
|
||||||
function Sparkline({ trend, className }: { trend: number, className?: string }) {
|
|
||||||
const isPositive = trend > 0
|
const isPositive = trend > 0
|
||||||
const isNeutral = trend === 0
|
const isNeutral = trend === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("flex items-center gap-1", className)}>
|
<svg width="40" height="16" viewBox="0 0 40 16" className="overflow-visible">
|
||||||
<svg width="40" height="16" viewBox="0 0 40 16" className="overflow-visible">
|
{isNeutral ? (
|
||||||
{isNeutral ? (
|
<line x1="0" y1="8" x2="40" y2="8" stroke="currentColor" className="text-foreground-muted" strokeWidth="1.5" />
|
||||||
<line x1="0" y1="8" x2="40" y2="8" stroke="currentColor" className="text-foreground-muted" strokeWidth="1.5" />
|
) : isPositive ? (
|
||||||
) : isPositive ? (
|
<polyline
|
||||||
<polyline
|
points="0,14 10,12 20,10 30,6 40,2"
|
||||||
points="0,14 10,12 20,10 30,6 40,2"
|
fill="none"
|
||||||
fill="none"
|
stroke="currentColor"
|
||||||
stroke="currentColor"
|
className="text-orange-400"
|
||||||
className="text-orange-400"
|
strokeWidth="1.5"
|
||||||
strokeWidth="1.5"
|
strokeLinecap="round"
|
||||||
strokeLinecap="round"
|
strokeLinejoin="round"
|
||||||
strokeLinejoin="round"
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<polyline
|
||||||
<polyline
|
points="0,2 10,4 20,8 30,12 40,14"
|
||||||
points="0,2 10,4 20,8 30,12 40,14"
|
fill="none"
|
||||||
fill="none"
|
stroke="currentColor"
|
||||||
stroke="currentColor"
|
className="text-accent"
|
||||||
className="text-accent"
|
strokeWidth="1.5"
|
||||||
strokeWidth="1.5"
|
strokeLinecap="round"
|
||||||
strokeLinecap="round"
|
strokeLinejoin="round"
|
||||||
strokeLinejoin="round"
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
</svg>
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
export default function TLDPricingPage() {
|
export default function TLDPricingPage() {
|
||||||
const { subscription } = useStore()
|
const { subscription } = useStore()
|
||||||
@ -108,25 +114,19 @@ export default function TLDPricingPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [sortBy, setSortBy] = useState<'popularity' | 'price_asc' | 'price_desc' | 'change' | 'risk'>('popularity')
|
const [sortBy, setSortBy] = useState('popularity')
|
||||||
const [category, setCategory] = useState<CategoryKey>('all')
|
const [category, setCategory] = useState('all')
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [hoveredTld, setHoveredTld] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
const loadTLDData = useCallback(async () => {
|
||||||
loadTLDData()
|
|
||||||
}, [page, sortBy])
|
|
||||||
|
|
||||||
const loadTLDData = async () => {
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const response = await api.getTldOverview(
|
const response = await api.getTldOverview(
|
||||||
50,
|
50,
|
||||||
page * 50,
|
page * 50,
|
||||||
sortBy === 'risk' ? 'popularity' : sortBy === 'change' ? 'popularity' : sortBy,
|
sortBy === 'risk' || sortBy === 'change' ? 'popularity' : sortBy as any,
|
||||||
)
|
)
|
||||||
// Map API response to component interface
|
|
||||||
const mapped: TLDData[] = (response.tlds || []).map((tld) => ({
|
const mapped: TLDData[] = (response.tlds || []).map((tld) => ({
|
||||||
tld: tld.tld,
|
tld: tld.tld,
|
||||||
min_price: tld.min_registration_price,
|
min_price: tld.min_registration_price,
|
||||||
@ -149,186 +149,195 @@ export default function TLDPricingPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [page, sortBy])
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
useEffect(() => {
|
||||||
|
loadTLDData()
|
||||||
|
}, [loadTLDData])
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
await loadTLDData()
|
await loadTLDData()
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
}
|
}, [loadTLDData])
|
||||||
|
|
||||||
// Apply category and search filters
|
// Memoized filtered and sorted data
|
||||||
const filteredData = tldData
|
const sortedData = useMemo(() => {
|
||||||
.filter(tld => CATEGORIES[category].filter(tld))
|
let data = tldData.filter(CATEGORY_FILTERS[category] || (() => true))
|
||||||
.filter(tld => tld.tld.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
||||||
|
if (searchQuery) {
|
||||||
// Sort by risk if selected
|
const q = searchQuery.toLowerCase()
|
||||||
const sortedData = sortBy === 'risk'
|
data = data.filter(tld => tld.tld.toLowerCase().includes(q))
|
||||||
? [...filteredData].sort((a, b) => {
|
}
|
||||||
const riskOrder = { high: 0, medium: 1, low: 2 }
|
|
||||||
return riskOrder[getRiskInfo(a).level] - riskOrder[getRiskInfo(b).level]
|
if (sortBy === 'risk') {
|
||||||
})
|
const riskOrder = { high: 0, medium: 1, low: 2 }
|
||||||
: filteredData
|
data = [...data].sort((a, b) => riskOrder[a.risk_level] - riskOrder[b.risk_level])
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}, [tldData, category, searchQuery, sortBy])
|
||||||
|
|
||||||
const getTrendIcon = (change: number | undefined) => {
|
// Memoized stats
|
||||||
if (!change || change === 0) return <Minus className="w-4 h-4 text-foreground-muted" />
|
const stats = useMemo(() => {
|
||||||
if (change > 0) return <TrendingUp className="w-4 h-4 text-orange-400" />
|
const lowestPrice = tldData.length > 0
|
||||||
return <TrendingDown className="w-4 h-4 text-accent" />
|
? 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])
|
||||||
|
|
||||||
// Calculate stats
|
const subtitle = 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
|
|
||||||
|
|
||||||
// Dynamic subtitle
|
|
||||||
const getSubtitle = () => {
|
|
||||||
if (loading && total === 0) return 'Loading TLD pricing data...'
|
if (loading && total === 0) return 'Loading TLD pricing data...'
|
||||||
if (total === 0) return 'No TLD data available'
|
if (total === 0) return 'No TLD data available'
|
||||||
return `Tracking ${total.toLocaleString()} TLDs • Updated daily`
|
return `Tracking ${total.toLocaleString()} TLDs • Updated daily`
|
||||||
}
|
}, [loading, total])
|
||||||
|
|
||||||
const getRiskBadge = (tld: TLDData) => {
|
// Memoized columns
|
||||||
const { level, reason } = getRiskInfo(tld)
|
const columns = useMemo(() => [
|
||||||
return (
|
{
|
||||||
<span className={clsx(
|
key: 'tld',
|
||||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
header: 'TLD',
|
||||||
level === 'high' && "bg-red-500/10 text-red-400",
|
width: '100px',
|
||||||
level === 'medium' && "bg-amber-500/10 text-amber-400",
|
render: (tld: TLDData) => (
|
||||||
level === 'low' && "bg-accent/10 text-accent"
|
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||||
)}>
|
.{tld.tld}
|
||||||
<span className={clsx(
|
|
||||||
"w-2.5 h-2.5 rounded-full",
|
|
||||||
level === 'high' && "bg-red-400",
|
|
||||||
level === 'medium' && "bg-amber-400",
|
|
||||||
level === 'low' && "bg-accent"
|
|
||||||
)} />
|
|
||||||
<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>
|
</span>
|
||||||
)
|
),
|
||||||
}
|
},
|
||||||
return null
|
{
|
||||||
}
|
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 (
|
return (
|
||||||
<CommandCenterLayout
|
<CommandCenterLayout
|
||||||
title="TLD Pricing"
|
title="TLD Pricing"
|
||||||
subtitle={getSubtitle()}
|
subtitle={subtitle}
|
||||||
actions={
|
actions={
|
||||||
<button
|
<ActionButton onClick={handleRefresh} disabled={refreshing} variant="ghost" icon={refreshing ? Loader2 : RefreshCw}>
|
||||||
onClick={handleRefresh}
|
{refreshing ? '' : 'Refresh'}
|
||||||
disabled={refreshing}
|
</ActionButton>
|
||||||
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>
|
<PageContainer>
|
||||||
{/* Stats Overview */}
|
{/* Stats Overview */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard
|
<StatCard title="TLDs Tracked" value={total > 0 ? total.toLocaleString() : '—'} subtitle="updated daily" icon={Globe} accent />
|
||||||
title="TLDs Tracked"
|
<StatCard title="Lowest Price" value={total > 0 ? `$${stats.lowestPrice.toFixed(2)}` : '—'} icon={DollarSign} />
|
||||||
value={total > 0 ? total.toLocaleString() : '—'}
|
<StatCard title="Hottest TLD" value={total > 0 ? `.${stats.hottestTld}` : '—'} subtitle="rising prices" icon={TrendingUp} />
|
||||||
subtitle="updated daily"
|
<StatCard title="Renewal Traps" value={stats.trapCount.toString()} subtitle="high renewal ratio" icon={AlertTriangle} />
|
||||||
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="Renewal Traps"
|
|
||||||
value={trapCount > 0 ? trapCount.toString() : '0'}
|
|
||||||
subtitle="high renewal ratio"
|
|
||||||
icon={AlertTriangle}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category Tabs */}
|
{/* Category Tabs */}
|
||||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
<TabBar
|
||||||
{(Object.keys(CATEGORIES) as CategoryKey[]).map((key) => {
|
tabs={CATEGORIES.map(c => ({ id: c.id, label: c.label, icon: c.icon }))}
|
||||||
const cat = CATEGORIES[key]
|
activeTab={category}
|
||||||
const Icon = cat.icon
|
onChange={setCategory}
|
||||||
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 */}
|
{/* Filters */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<FilterBar>
|
||||||
<div className="relative flex-1 max-w-md">
|
<SearchInput
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
|
value={searchQuery}
|
||||||
<input
|
onChange={setSearchQuery}
|
||||||
type="text"
|
placeholder="Search TLDs (e.g. com, io, dev)..."
|
||||||
value={searchQuery}
|
className="flex-1 max-w-md"
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
/>
|
||||||
placeholder="Search TLDs (e.g. com, io, dev)..."
|
<SelectDropdown value={sortBy} onChange={setSortBy} options={SORT_OPTIONS} />
|
||||||
className="w-full h-11 pl-11 pr-10 bg-background-secondary/50 border border-border/50 rounded-xl
|
</FilterBar>
|
||||||
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>
|
|
||||||
<option value="risk">By Risk Level</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>
|
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex flex-wrap items-center gap-4 text-xs text-foreground-muted">
|
<div className="flex flex-wrap items-center gap-4 text-xs text-foreground-muted">
|
||||||
@ -347,102 +356,7 @@ export default function TLDPricingPage() {
|
|||||||
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />}
|
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />}
|
||||||
emptyTitle="No TLDs found"
|
emptyTitle="No TLDs found"
|
||||||
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
|
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
|
||||||
columns={[
|
columns={columns}
|
||||||
{
|
|
||||||
key: 'tld',
|
|
||||||
header: 'TLD',
|
|
||||||
width: '100px',
|
|
||||||
render: (tld) => (
|
|
||||||
<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) => (
|
|
||||||
<Sparkline trend={tld.price_change_1y || 0} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'buy_price',
|
|
||||||
header: 'Buy (1y)',
|
|
||||||
align: 'right',
|
|
||||||
width: '100px',
|
|
||||||
render: (tld) => (
|
|
||||||
<span className="font-semibold text-foreground tabular-nums">${tld.min_price.toFixed(2)}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'renew_price',
|
|
||||||
header: 'Renew (1y)',
|
|
||||||
align: 'right',
|
|
||||||
width: '120px',
|
|
||||||
render: (tld) => (
|
|
||||||
<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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'change_1y',
|
|
||||||
header: '1y Change',
|
|
||||||
align: 'right',
|
|
||||||
width: '100px',
|
|
||||||
hideOnMobile: true,
|
|
||||||
render: (tld) => {
|
|
||||||
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 Change',
|
|
||||||
align: 'right',
|
|
||||||
width: '100px',
|
|
||||||
hideOnMobile: true,
|
|
||||||
render: (tld) => {
|
|
||||||
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',
|
|
||||||
width: '130px',
|
|
||||||
render: (tld) => getRiskBadge(tld),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
align: 'right',
|
|
||||||
width: '80px',
|
|
||||||
render: () => (
|
|
||||||
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
@ -451,9 +365,7 @@ export default function TLDPricingPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage(Math.max(0, page - 1))}
|
onClick={() => setPage(Math.max(0, page - 1))}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
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"
|
||||||
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
@ -463,9 +375,7 @@ export default function TLDPricingPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage(page + 1)}
|
onClick={() => setPage(page + 1)}
|
||||||
disabled={(page + 1) * 50 >= total}
|
disabled={(page + 1) * 50 >= total}
|
||||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
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"
|
||||||
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
import { PageContainer } from '@/components/PremiumTable'
|
import { PageContainer, TabBar } from '@/components/PremiumTable'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api, PriceAlert } from '@/lib/api'
|
import { api, PriceAlert } from '@/lib/api'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -1,10 +1,20 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api, DomainHealthReport, HealthStatus } from '@/lib/api'
|
import { api, DomainHealthReport, HealthStatus } from '@/lib/api'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
import { PremiumTable, Badge, StatCard, PageContainer, TableActionButton } from '@/components/PremiumTable'
|
import {
|
||||||
|
PremiumTable,
|
||||||
|
Badge,
|
||||||
|
StatCard,
|
||||||
|
PageContainer,
|
||||||
|
TableActionButton,
|
||||||
|
SearchInput,
|
||||||
|
TabBar,
|
||||||
|
FilterBar,
|
||||||
|
ActionButton,
|
||||||
|
} from '@/components/PremiumTable'
|
||||||
import { Toast, useToast } from '@/components/Toast'
|
import { Toast, useToast } from '@/components/Toast'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@ -14,7 +24,6 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
BellOff,
|
BellOff,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Search,
|
|
||||||
Eye,
|
Eye,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
@ -73,6 +82,8 @@ const healthStatusConfig: Record<HealthStatus, {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FilterStatus = 'all' | 'available' | 'watching'
|
||||||
|
|
||||||
export default function WatchlistPage() {
|
export default function WatchlistPage() {
|
||||||
const { domains, addDomain, deleteDomain, refreshDomain, subscription } = useStore()
|
const { domains, addDomain, deleteDomain, refreshDomain, subscription } = useStore()
|
||||||
const { toast, showToast, hideToast } = useToast()
|
const { toast, showToast, hideToast } = useToast()
|
||||||
@ -82,7 +93,7 @@ export default function WatchlistPage() {
|
|||||||
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
const [togglingNotifyId, setTogglingNotifyId] = useState<number | null>(null)
|
const [togglingNotifyId, setTogglingNotifyId] = useState<number | null>(null)
|
||||||
const [filterStatus, setFilterStatus] = useState<'all' | 'available' | 'watching'>('all')
|
const [filterStatus, setFilterStatus] = useState<FilterStatus>('all')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
// Health check state
|
// Health check state
|
||||||
@ -90,24 +101,39 @@ export default function WatchlistPage() {
|
|||||||
const [loadingHealth, setLoadingHealth] = useState<Record<number, boolean>>({})
|
const [loadingHealth, setLoadingHealth] = useState<Record<number, boolean>>({})
|
||||||
const [selectedHealthDomainId, setSelectedHealthDomainId] = useState<number | null>(null)
|
const [selectedHealthDomainId, setSelectedHealthDomainId] = useState<number | null>(null)
|
||||||
|
|
||||||
// Filter domains
|
// Memoized stats - avoids recalculation on every render
|
||||||
const filteredDomains = domains?.filter(domain => {
|
const stats = useMemo(() => ({
|
||||||
if (searchQuery && !domain.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
availableCount: domains?.filter(d => d.is_available).length || 0,
|
||||||
return false
|
watchingCount: domains?.filter(d => !d.is_available).length || 0,
|
||||||
}
|
domainsUsed: domains?.length || 0,
|
||||||
if (filterStatus === 'available' && !domain.is_available) return false
|
domainLimit: subscription?.domain_limit || 5,
|
||||||
if (filterStatus === 'watching' && domain.is_available) return false
|
}), [domains, subscription?.domain_limit])
|
||||||
return true
|
|
||||||
}) || []
|
|
||||||
|
|
||||||
// Stats
|
const canAddMore = stats.domainsUsed < stats.domainLimit
|
||||||
const availableCount = domains?.filter(d => d.is_available).length || 0
|
|
||||||
const watchingCount = domains?.filter(d => !d.is_available).length || 0
|
|
||||||
const domainsUsed = domains?.length || 0
|
|
||||||
const domainLimit = subscription?.domain_limit || 5
|
|
||||||
const canAddMore = domainsUsed < domainLimit
|
|
||||||
|
|
||||||
const handleAddDomain = async (e: React.FormEvent) => {
|
// Memoized filtered domains
|
||||||
|
const filteredDomains = useMemo(() => {
|
||||||
|
if (!domains) return []
|
||||||
|
|
||||||
|
return domains.filter(domain => {
|
||||||
|
if (searchQuery && !domain.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (filterStatus === 'available' && !domain.is_available) return false
|
||||||
|
if (filterStatus === 'watching' && domain.is_available) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [domains, searchQuery, filterStatus])
|
||||||
|
|
||||||
|
// Memoized tabs config
|
||||||
|
const tabs = useMemo(() => [
|
||||||
|
{ id: 'all', label: 'All', count: stats.domainsUsed },
|
||||||
|
{ id: 'available', label: 'Available', count: stats.availableCount, color: 'accent' as const },
|
||||||
|
{ id: 'watching', label: 'Monitoring', count: stats.watchingCount },
|
||||||
|
], [stats])
|
||||||
|
|
||||||
|
// Callbacks - prevent recreation on every render
|
||||||
|
const handleAddDomain = useCallback(async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!newDomain.trim()) return
|
if (!newDomain.trim()) return
|
||||||
|
|
||||||
@ -121,9 +147,9 @@ export default function WatchlistPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setAdding(false)
|
setAdding(false)
|
||||||
}
|
}
|
||||||
}
|
}, [newDomain, addDomain, showToast])
|
||||||
|
|
||||||
const handleRefresh = async (id: number) => {
|
const handleRefresh = useCallback(async (id: number) => {
|
||||||
setRefreshingId(id)
|
setRefreshingId(id)
|
||||||
try {
|
try {
|
||||||
await refreshDomain(id)
|
await refreshDomain(id)
|
||||||
@ -133,9 +159,9 @@ export default function WatchlistPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setRefreshingId(null)
|
setRefreshingId(null)
|
||||||
}
|
}
|
||||||
}
|
}, [refreshDomain, showToast])
|
||||||
|
|
||||||
const handleDelete = async (id: number, name: string) => {
|
const handleDelete = useCallback(async (id: number, name: string) => {
|
||||||
if (!confirm(`Remove ${name} from your watchlist?`)) return
|
if (!confirm(`Remove ${name} from your watchlist?`)) return
|
||||||
|
|
||||||
setDeletingId(id)
|
setDeletingId(id)
|
||||||
@ -147,24 +173,21 @@ export default function WatchlistPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null)
|
setDeletingId(null)
|
||||||
}
|
}
|
||||||
}
|
}, [deleteDomain, showToast])
|
||||||
|
|
||||||
const handleToggleNotify = async (id: number, currentState: boolean) => {
|
const handleToggleNotify = useCallback(async (id: number, currentState: boolean) => {
|
||||||
setTogglingNotifyId(id)
|
setTogglingNotifyId(id)
|
||||||
try {
|
try {
|
||||||
await api.updateDomainNotify(id, !currentState)
|
await api.updateDomainNotify(id, !currentState)
|
||||||
showToast(
|
showToast(!currentState ? 'Notifications enabled' : 'Notifications disabled', 'success')
|
||||||
!currentState ? 'Notifications enabled' : 'Notifications disabled',
|
|
||||||
'success'
|
|
||||||
)
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast(err.message || 'Failed to update', 'error')
|
showToast(err.message || 'Failed to update', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setTogglingNotifyId(null)
|
setTogglingNotifyId(null)
|
||||||
}
|
}
|
||||||
}
|
}, [showToast])
|
||||||
|
|
||||||
const handleHealthCheck = async (domainId: number) => {
|
const handleHealthCheck = useCallback(async (domainId: number) => {
|
||||||
if (loadingHealth[domainId]) return
|
if (loadingHealth[domainId]) return
|
||||||
|
|
||||||
setLoadingHealth(prev => ({ ...prev, [domainId]: true }))
|
setLoadingHealth(prev => ({ ...prev, [domainId]: true }))
|
||||||
@ -177,58 +200,169 @@ export default function WatchlistPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingHealth(prev => ({ ...prev, [domainId]: false }))
|
setLoadingHealth(prev => ({ ...prev, [domainId]: false }))
|
||||||
}
|
}
|
||||||
}
|
}, [loadingHealth, showToast])
|
||||||
|
|
||||||
// Dynamic subtitle
|
// Dynamic subtitle
|
||||||
const getSubtitle = () => {
|
const subtitle = useMemo(() => {
|
||||||
if (domainsUsed === 0) return 'Start tracking domains to monitor their availability'
|
if (stats.domainsUsed === 0) return 'Start tracking domains to monitor their availability'
|
||||||
return `Monitoring ${domainsUsed} domain${domainsUsed !== 1 ? 's' : ''} • ${domainLimit === -1 ? 'Unlimited' : `${domainLimit - domainsUsed} slots left`}`
|
return `Monitoring ${stats.domainsUsed} domain${stats.domainsUsed !== 1 ? 's' : ''} • ${stats.domainLimit === -1 ? 'Unlimited' : `${stats.domainLimit - stats.domainsUsed} slots left`}`
|
||||||
}
|
}, [stats])
|
||||||
|
|
||||||
|
// Memoized columns config
|
||||||
|
const columns = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'domain',
|
||||||
|
header: 'Domain',
|
||||||
|
render: (domain: any) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<span className={clsx(
|
||||||
|
"block w-3 h-3 rounded-full",
|
||||||
|
domain.is_available ? "bg-accent" : "bg-foreground-muted/50"
|
||||||
|
)} />
|
||||||
|
{domain.is_available && (
|
||||||
|
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-mono font-medium text-foreground">{domain.name}</span>
|
||||||
|
{domain.is_available && (
|
||||||
|
<Badge variant="success" size="xs" className="ml-2">AVAILABLE</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
align: 'left' as const,
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (domain: any) => {
|
||||||
|
const health = healthReports[domain.id]
|
||||||
|
if (health) {
|
||||||
|
const config = healthStatusConfig[health.status]
|
||||||
|
const Icon = config.icon
|
||||||
|
return (
|
||||||
|
<div className={clsx("inline-flex items-center gap-2 px-2.5 py-1 rounded-lg border", config.bgColor)}>
|
||||||
|
<Icon className={clsx("w-3.5 h-3.5", config.color)} />
|
||||||
|
<span className={clsx("text-xs font-medium", config.color)}>{config.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={clsx(
|
||||||
|
"text-sm",
|
||||||
|
domain.is_available ? "text-accent font-medium" : "text-foreground-muted"
|
||||||
|
)}>
|
||||||
|
{domain.is_available ? 'Ready to pounce!' : 'Monitoring...'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'notifications',
|
||||||
|
header: 'Alerts',
|
||||||
|
align: 'center' as const,
|
||||||
|
width: '80px',
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (domain: any) => (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleToggleNotify(domain.id, domain.notify_on_available)
|
||||||
|
}}
|
||||||
|
disabled={togglingNotifyId === domain.id}
|
||||||
|
className={clsx(
|
||||||
|
"p-2 rounded-lg transition-colors",
|
||||||
|
domain.notify_on_available
|
||||||
|
? "bg-accent/10 text-accent hover:bg-accent/20"
|
||||||
|
: "text-foreground-muted hover:bg-foreground/5"
|
||||||
|
)}
|
||||||
|
title={domain.notify_on_available ? "Disable alerts" : "Enable alerts"}
|
||||||
|
>
|
||||||
|
{togglingNotifyId === domain.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : domain.notify_on_available ? (
|
||||||
|
<Bell className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<BellOff className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (domain: any) => (
|
||||||
|
<div className="flex items-center gap-1 justify-end">
|
||||||
|
<TableActionButton
|
||||||
|
icon={Activity}
|
||||||
|
onClick={() => handleHealthCheck(domain.id)}
|
||||||
|
loading={loadingHealth[domain.id]}
|
||||||
|
title="Health check (DNS, HTTP, SSL)"
|
||||||
|
variant={healthReports[domain.id] ? 'accent' : 'default'}
|
||||||
|
/>
|
||||||
|
<TableActionButton
|
||||||
|
icon={RefreshCw}
|
||||||
|
onClick={() => handleRefresh(domain.id)}
|
||||||
|
loading={refreshingId === domain.id}
|
||||||
|
title="Refresh availability"
|
||||||
|
/>
|
||||||
|
<TableActionButton
|
||||||
|
icon={Trash2}
|
||||||
|
onClick={() => handleDelete(domain.id, domain.name)}
|
||||||
|
variant="danger"
|
||||||
|
loading={deletingId === domain.id}
|
||||||
|
title="Remove"
|
||||||
|
/>
|
||||||
|
{domain.is_available && (
|
||||||
|
<a
|
||||||
|
href={`https://www.namecheap.com/domains/registration/results/?domain=${domain.name}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-accent text-background text-xs font-medium
|
||||||
|
rounded-lg hover:bg-accent-hover transition-colors ml-1"
|
||||||
|
>
|
||||||
|
Register <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [healthReports, togglingNotifyId, loadingHealth, refreshingId, deletingId, handleToggleNotify, handleHealthCheck, handleRefresh, handleDelete])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandCenterLayout
|
<CommandCenterLayout title="Watchlist" subtitle={subtitle}>
|
||||||
title="Watchlist"
|
|
||||||
subtitle={getSubtitle()}
|
|
||||||
>
|
|
||||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||||
|
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard title="Total Watched" value={domainsUsed} icon={Eye} />
|
<StatCard title="Total Watched" value={stats.domainsUsed} icon={Eye} />
|
||||||
<StatCard title="Available" value={availableCount} icon={Sparkles} />
|
<StatCard title="Available" value={stats.availableCount} icon={Sparkles} />
|
||||||
<StatCard title="Monitoring" value={watchingCount} subtitle="active checks" icon={Activity} />
|
<StatCard title="Monitoring" value={stats.watchingCount} subtitle="active checks" icon={Activity} />
|
||||||
<StatCard title="Plan Limit" value={domainLimit === -1 ? '∞' : domainLimit} subtitle={`${domainsUsed} used`} icon={Shield} />
|
<StatCard title="Plan Limit" value={stats.domainLimit === -1 ? '∞' : stats.domainLimit} subtitle={`${stats.domainsUsed} used`} icon={Shield} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Domain Form */}
|
{/* Add Domain Form */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<FilterBar>
|
||||||
<div className="relative flex-1">
|
<SearchInput
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
value={newDomain}
|
||||||
<input
|
onChange={setNewDomain}
|
||||||
type="text"
|
placeholder="Enter domain to track (e.g., dream.com)"
|
||||||
value={newDomain}
|
className="flex-1"
|
||||||
onChange={(e) => setNewDomain(e.target.value)}
|
/>
|
||||||
placeholder="Enter domain to track (e.g., dream.com)"
|
<ActionButton
|
||||||
disabled={!canAddMore}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleAddDomain(e)}
|
|
||||||
className="w-full h-11 pl-11 pr-4 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
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleAddDomain}
|
onClick={handleAddDomain}
|
||||||
disabled={adding || !newDomain.trim() || !canAddMore}
|
disabled={adding || !newDomain.trim() || !canAddMore}
|
||||||
className="flex items-center justify-center gap-2 h-11 px-6 bg-gradient-to-r from-accent to-accent/80
|
icon={adding ? Loader2 : Plus}
|
||||||
text-background rounded-xl font-medium hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.4)]
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
|
||||||
>
|
>
|
||||||
{adding ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
Add Domain
|
||||||
<span>Add Domain</span>
|
</ActionButton>
|
||||||
</button>
|
</FilterBar>
|
||||||
</div>
|
|
||||||
|
|
||||||
{!canAddMore && (
|
{!canAddMore && (
|
||||||
<div className="flex items-center justify-between p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl">
|
<div className="flex items-center justify-between p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl">
|
||||||
@ -245,186 +379,28 @@ export default function WatchlistPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
|
<FilterBar className="justify-between">
|
||||||
<div className="flex flex-wrap items-center gap-2 p-1.5 bg-background-secondary/30 border border-border/30 rounded-2xl w-fit">
|
<TabBar
|
||||||
{[
|
tabs={tabs}
|
||||||
{ id: 'all' as const, label: 'All', count: domainsUsed },
|
activeTab={filterStatus}
|
||||||
{ id: 'available' as const, label: 'Available', count: availableCount, color: 'accent' },
|
onChange={(id) => setFilterStatus(id as FilterStatus)}
|
||||||
{ id: 'watching' as const, label: 'Monitoring', count: watchingCount },
|
/>
|
||||||
].map((tab) => (
|
<SearchInput
|
||||||
<button
|
value={searchQuery}
|
||||||
key={tab.id}
|
onChange={setSearchQuery}
|
||||||
onClick={() => setFilterStatus(tab.id)}
|
placeholder="Filter domains..."
|
||||||
className={clsx(
|
className="w-full sm:w-64"
|
||||||
"flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-xl transition-all",
|
/>
|
||||||
filterStatus === tab.id
|
</FilterBar>
|
||||||
? tab.color === 'accent'
|
|
||||||
? "bg-gradient-to-r from-accent to-accent/80 text-background shadow-lg shadow-accent/20"
|
|
||||||
: "bg-foreground/10 text-foreground"
|
|
||||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.id === 'available' && <span className="w-2 h-2 rounded-full bg-accent" />}
|
|
||||||
{tab.id === 'watching' && <span className="w-2 h-2 rounded-full bg-foreground-muted" />}
|
|
||||||
<span>{tab.label}</span>
|
|
||||||
<span className={clsx(
|
|
||||||
"text-xs px-1.5 py-0.5 rounded tabular-nums",
|
|
||||||
filterStatus === tab.id ? "bg-background/20" : "bg-foreground/10"
|
|
||||||
)}>{tab.count}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative max-w-xs">
|
|
||||||
<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="Filter domains..."
|
|
||||||
className="w-full h-10 pl-10 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"
|
|
||||||
/>
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* Domain Table */}
|
{/* Domain Table */}
|
||||||
<PremiumTable
|
<PremiumTable
|
||||||
data={filteredDomains}
|
data={filteredDomains}
|
||||||
keyExtractor={(d) => d.id}
|
keyExtractor={(d) => d.id}
|
||||||
emptyIcon={<Eye className="w-12 h-12 text-foreground-subtle" />}
|
emptyIcon={<Eye className="w-12 h-12 text-foreground-subtle" />}
|
||||||
emptyTitle={domainsUsed === 0 ? "Your watchlist is empty" : "No domains match your filters"}
|
emptyTitle={stats.domainsUsed === 0 ? "Your watchlist is empty" : "No domains match your filters"}
|
||||||
emptyDescription={domainsUsed === 0 ? "Add a domain above to start tracking" : "Try adjusting your filter criteria"}
|
emptyDescription={stats.domainsUsed === 0 ? "Add a domain above to start tracking" : "Try adjusting your filter criteria"}
|
||||||
columns={[
|
columns={columns}
|
||||||
{
|
|
||||||
key: 'domain',
|
|
||||||
header: 'Domain',
|
|
||||||
render: (domain) => (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="relative">
|
|
||||||
<span className={clsx(
|
|
||||||
"block w-3 h-3 rounded-full",
|
|
||||||
domain.is_available ? "bg-accent" : "bg-foreground-muted/50"
|
|
||||||
)} />
|
|
||||||
{domain.is_available && (
|
|
||||||
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-mono font-medium text-foreground">{domain.name}</span>
|
|
||||||
{domain.is_available && (
|
|
||||||
<Badge variant="success" size="xs" className="ml-2">AVAILABLE</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Status',
|
|
||||||
align: 'left',
|
|
||||||
hideOnMobile: true,
|
|
||||||
render: (domain) => {
|
|
||||||
const health = healthReports[domain.id]
|
|
||||||
if (health) {
|
|
||||||
const config = healthStatusConfig[health.status]
|
|
||||||
const Icon = config.icon
|
|
||||||
return (
|
|
||||||
<div className={clsx("inline-flex items-center gap-2 px-2.5 py-1 rounded-lg border", config.bgColor)}>
|
|
||||||
<Icon className={clsx("w-3.5 h-3.5", config.color)} />
|
|
||||||
<span className={clsx("text-xs font-medium", config.color)}>{config.label}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className={clsx(
|
|
||||||
"text-sm",
|
|
||||||
domain.is_available ? "text-accent font-medium" : "text-foreground-muted"
|
|
||||||
)}>
|
|
||||||
{domain.is_available ? 'Ready to pounce!' : 'Monitoring...'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'notifications',
|
|
||||||
header: 'Alerts',
|
|
||||||
align: 'center',
|
|
||||||
width: '80px',
|
|
||||||
hideOnMobile: true,
|
|
||||||
render: (domain) => (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleToggleNotify(domain.id, domain.notify_on_available)
|
|
||||||
}}
|
|
||||||
disabled={togglingNotifyId === domain.id}
|
|
||||||
className={clsx(
|
|
||||||
"p-2 rounded-lg transition-colors",
|
|
||||||
domain.notify_on_available
|
|
||||||
? "bg-accent/10 text-accent hover:bg-accent/20"
|
|
||||||
: "text-foreground-muted hover:bg-foreground/5"
|
|
||||||
)}
|
|
||||||
title={domain.notify_on_available ? "Disable alerts" : "Enable alerts"}
|
|
||||||
>
|
|
||||||
{togglingNotifyId === domain.id ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : domain.notify_on_available ? (
|
|
||||||
<Bell className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<BellOff className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
align: 'right',
|
|
||||||
render: (domain) => (
|
|
||||||
<div className="flex items-center gap-1 justify-end">
|
|
||||||
<TableActionButton
|
|
||||||
icon={Activity}
|
|
||||||
onClick={() => handleHealthCheck(domain.id)}
|
|
||||||
loading={loadingHealth[domain.id]}
|
|
||||||
title="Health check (DNS, HTTP, SSL)"
|
|
||||||
variant={healthReports[domain.id] ? 'accent' : 'default'}
|
|
||||||
/>
|
|
||||||
<TableActionButton
|
|
||||||
icon={RefreshCw}
|
|
||||||
onClick={() => handleRefresh(domain.id)}
|
|
||||||
loading={refreshingId === domain.id}
|
|
||||||
title="Refresh availability"
|
|
||||||
/>
|
|
||||||
<TableActionButton
|
|
||||||
icon={Trash2}
|
|
||||||
onClick={() => handleDelete(domain.id, domain.name)}
|
|
||||||
variant="danger"
|
|
||||||
loading={deletingId === domain.id}
|
|
||||||
title="Remove"
|
|
||||||
/>
|
|
||||||
{domain.is_available && (
|
|
||||||
<a
|
|
||||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${domain.name}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-2 bg-accent text-background text-xs font-medium
|
|
||||||
rounded-lg hover:bg-accent-hover transition-colors ml-1"
|
|
||||||
>
|
|
||||||
Register <ExternalLink className="w-3 h-3" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Health Report Modal */}
|
{/* Health Report Modal */}
|
||||||
@ -439,8 +415,14 @@ export default function WatchlistPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health Report Modal Component
|
// Health Report Modal Component - memoized
|
||||||
function HealthReportModal({ report, onClose }: { report: DomainHealthReport; onClose: () => void }) {
|
const HealthReportModal = memo(function HealthReportModal({
|
||||||
|
report,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
report: DomainHealthReport
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
const config = healthStatusConfig[report.status]
|
const config = healthStatusConfig[report.status]
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
|
|
||||||
@ -496,7 +478,7 @@ function HealthReportModal({ report, onClose }: { report: DomainHealthReport; on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Check Results */}
|
{/* Check Results */}
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4 max-h-80 overflow-y-auto">
|
||||||
{/* DNS */}
|
{/* DNS */}
|
||||||
{report.dns && (
|
{report.dns && (
|
||||||
<div className="p-4 bg-foreground/5 rounded-xl">
|
<div className="p-4 bg-foreground/5 rounded-xl">
|
||||||
@ -550,10 +532,8 @@ function HealthReportModal({ report, onClose }: { report: DomainHealthReport; on
|
|||||||
)}>
|
)}>
|
||||||
{report.http.is_reachable ? 'Reachable' : 'Unreachable'}
|
{report.http.is_reachable ? 'Reachable' : 'Unreachable'}
|
||||||
</span>
|
</span>
|
||||||
{report.http.status_code && (
|
{report.http.status_code && (
|
||||||
<span className="text-foreground-muted">
|
<span className="text-foreground-muted">HTTP {report.http.status_code}</span>
|
||||||
HTTP {report.http.status_code}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{report.http.is_parked && (
|
{report.http.is_parked && (
|
||||||
@ -637,4 +617,4 @@ function HealthReportModal({ report, onClose }: { report: DomainHealthReport; on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user