diff --git a/frontend/src/app/terminal/market/page.tsx b/frontend/src/app/terminal/market/page.tsx index 1ead235..c691c36 100644 --- a/frontend/src/app/terminal/market/page.tsx +++ b/frontend/src/app/terminal/market/page.tsx @@ -12,12 +12,15 @@ import { Zap, Filter, ChevronDown, + ChevronUp, Plus, Check, TrendingUp, RefreshCw, + ArrowUpDown, + Sparkles, + BarChart3, } from 'lucide-react' -import Link from 'next/link' import clsx from 'clsx' // ============================================================================ @@ -49,6 +52,7 @@ interface MarketItem { priceType: 'bid' | 'fixed' status: 'auction' | 'instant' timeLeft?: string + endTime?: string source: 'GoDaddy' | 'Sedo' | 'NameJet' | 'DropCatch' | 'Pounce' isPounce: boolean verified?: boolean @@ -57,6 +61,9 @@ interface MarketItem { numBids?: number } +type SortField = 'domain' | 'score' | 'price' | 'time' | 'source' +type SortDirection = 'asc' | 'desc' + // ============================================================================ // POUNCE SCORE ALGORITHM // ============================================================================ @@ -65,7 +72,7 @@ function calculatePounceScore(domain: string, tld: string, numBids?: number, age let score = 50 const name = domain.split('.')[0] - // Length bonus (shorter = better) + // Length bonus if (name.length <= 3) score += 30 else if (name.length === 4) score += 25 else if (name.length === 5) score += 20 @@ -83,7 +90,7 @@ function calculatePounceScore(domain: string, tld: string, numBids?: number, age else if (ageYears && ageYears > 10) score += 7 else if (ageYears && ageYears > 5) score += 3 - // Activity bonus (more bids = more valuable) + // Activity bonus if (numBids && numBids >= 20) score += 8 else if (numBids && numBids >= 10) score += 5 else if (numBids && numBids >= 5) score += 2 @@ -92,7 +99,7 @@ function calculatePounceScore(domain: string, tld: string, numBids?: number, age if (name.includes('-')) score -= 25 if (/\d/.test(name) && name.length > 3) score -= 20 if (name.length > 15) score -= 15 - if (/(.)\1{2,}/.test(name)) score -= 10 // repeated characters + if (/(.)\1{2,}/.test(name)) score -= 10 return Math.max(0, Math.min(100, score)) } @@ -106,12 +113,28 @@ function isSpamDomain(domain: string, tld: string): boolean { return false } +// Parse time remaining to seconds for sorting +function parseTimeToSeconds(timeStr?: string): number { + if (!timeStr) return Infinity + + let seconds = 0 + const days = timeStr.match(/(\d+)d/) + const hours = timeStr.match(/(\d+)h/) + const mins = timeStr.match(/(\d+)m/) + + if (days) seconds += parseInt(days[1]) * 86400 + if (hours) seconds += parseInt(hours[1]) * 3600 + if (mins) seconds += parseInt(mins[1]) * 60 + + return seconds || Infinity +} + // ============================================================================ // COMPONENTS // ============================================================================ -// Score Badge with color coding -function ScoreBadge({ score, showLabel = false }: { score: number; showLabel?: boolean }) { +// Score Badge +function ScoreBadge({ score }: { score: number }) { const color = score >= 80 ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : score >= 40 @@ -119,13 +142,12 @@ function ScoreBadge({ score, showLabel = false }: { score: number; showLabel?: b : 'bg-red-500/20 text-red-400 border-red-500/30' return ( -
{score} - {showLabel && pts} -
+ ) } @@ -133,25 +155,22 @@ function ScoreBadge({ score, showLabel = false }: { score: number; showLabel?: b function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }) { if (isPounce) { return ( -
- - Pounce +
+ + Pounce
) } const colors: Record = { - GoDaddy: 'bg-orange-500/10 border-orange-500/20 text-orange-400', - Sedo: 'bg-blue-500/10 border-blue-500/20 text-blue-400', - NameJet: 'bg-purple-500/10 border-purple-500/20 text-purple-400', - DropCatch: 'bg-cyan-500/10 border-cyan-500/20 text-cyan-400', + GoDaddy: 'text-orange-400/80', + Sedo: 'text-blue-400/80', + NameJet: 'text-purple-400/80', + DropCatch: 'text-cyan-400/80', } return ( - + {source} ) @@ -161,31 +180,28 @@ function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean } function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; timeLeft?: string }) { if (status === 'instant') { return ( -
- - Instant +
+ + Instant
) } - // Check urgency const isUrgent = timeLeft?.includes('m') && !timeLeft?.includes('d') && !timeLeft?.includes('h') const isWarning = timeLeft?.includes('h') && parseInt(timeLeft) <= 4 return (
{timeLeft} @@ -193,60 +209,83 @@ function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; time ) } -// Toggle Button -function ToggleButton({ - active, - onClick, - children +// Sortable Column Header +function SortHeader({ + label, + field, + currentSort, + currentDirection, + onSort, + align = 'left' }: { - active: boolean - onClick: () => void - children: React.ReactNode + label: string + field: SortField + currentSort: SortField + currentDirection: SortDirection + onSort: (field: SortField) => void + align?: 'left' | 'center' | 'right' }) { + const isActive = currentSort === field + return ( ) } -// Dropdown Select -function DropdownSelect({ - value, - onChange, - options, - label -}: { - value: string - onChange: (v: string) => void - options: { value: string; label: string }[] - label: string -}) { +// Toggle Button +function ToggleButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { return ( -
- - -
+ + ) +} + +// Dropdown +function Dropdown({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { value: string; label: string }[] }) { + return ( + ) } @@ -255,25 +294,28 @@ function DropdownSelect({ // ============================================================================ export default function MarketPage() { - const { isAuthenticated, subscription } = useStore() + const { subscription } = useStore() - // Data State + // Data const [auctions, setAuctions] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) - // Filter State - const [hideSpam, setHideSpam] = useState(true) // Default: ON + // Filters + const [hideSpam, setHideSpam] = useState(true) const [pounceOnly, setPounceOnly] = useState(false) const [selectedTld, setSelectedTld] = useState('all') const [selectedPrice, setSelectedPrice] = useState('all') const [searchQuery, setSearchQuery] = useState('') - // Watchlist State + // Sorting + const [sortField, setSortField] = useState('score') + const [sortDirection, setSortDirection] = useState('desc') + + // Watchlist const [trackedDomains, setTrackedDomains] = useState>(new Set()) const [trackingInProgress, setTrackingInProgress] = useState(null) - // Options const TLD_OPTIONS = [ { value: 'all', label: 'All TLDs' }, { value: 'com', label: '.com' }, @@ -286,7 +328,7 @@ export default function MarketPage() { const PRICE_OPTIONS = [ { value: 'all', label: 'Any Price' }, { value: '100', label: '< $100' }, - { value: '1000', label: '< $1,000' }, + { value: '1000', label: '< $1k' }, { value: '10000', label: 'High Roller' }, ] @@ -297,7 +339,7 @@ export default function MarketPage() { const data = await api.getAuctions() setAuctions(data.auctions || []) } catch (error) { - console.error('Failed to load market data:', error) + console.error('Failed to load:', error) } finally { setLoading(false) } @@ -313,293 +355,281 @@ export default function MarketPage() { setRefreshing(false) }, [loadData]) + const handleSort = useCallback((field: SortField) => { + if (sortField === field) { + setSortDirection(d => d === 'asc' ? 'desc' : 'asc') + } else { + setSortField(field) + setSortDirection(field === 'domain' || field === 'source' ? 'asc' : 'desc') + } + }, [sortField]) + const handleTrack = useCallback(async (domain: string) => { if (trackedDomains.has(domain) || trackingInProgress) return - setTrackingInProgress(domain) try { await api.addDomain(domain) setTrackedDomains(prev => new Set([...Array.from(prev), domain])) } catch (error) { - console.error('Failed to track:', error) + console.error('Failed:', error) } finally { setTrackingInProgress(null) } }, [trackedDomains, trackingInProgress]) - // Transform and Filter Data + // Process Data const marketItems = useMemo(() => { - // Convert auctions to market items - const items: MarketItem[] = auctions.map(auction => ({ - id: `${auction.domain}-${auction.platform}`, - domain: auction.domain, - pounceScore: calculatePounceScore(auction.domain, auction.tld, auction.num_bids, auction.age_years ?? undefined), - price: auction.current_bid, + let items: MarketItem[] = auctions.map(a => ({ + id: `${a.domain}-${a.platform}`, + domain: a.domain, + pounceScore: calculatePounceScore(a.domain, a.tld, a.num_bids, a.age_years ?? undefined), + price: a.current_bid, priceType: 'bid' as const, status: 'auction' as const, - timeLeft: auction.time_remaining, - source: auction.platform as any, + timeLeft: a.time_remaining, + endTime: a.end_time, + source: a.platform as any, isPounce: false, - affiliateUrl: auction.affiliate_url, - tld: auction.tld, - numBids: auction.num_bids, + affiliateUrl: a.affiliate_url, + tld: a.tld, + numBids: a.num_bids, })) - // Apply Filters - let filtered = items - - // 1. Hide Spam (Default: ON) - if (hideSpam) { - filtered = filtered.filter(item => !isSpamDomain(item.domain, item.tld)) - } - - // 2. Pounce Only - if (pounceOnly) { - filtered = filtered.filter(item => item.isPounce) - } - - // 3. TLD Filter - if (selectedTld !== 'all') { - filtered = filtered.filter(item => item.tld === selectedTld) - } - - // 4. Price Filter + // Filter + if (hideSpam) items = items.filter(i => !isSpamDomain(i.domain, i.tld)) + if (pounceOnly) items = items.filter(i => i.isPounce) + if (selectedTld !== 'all') items = items.filter(i => i.tld === selectedTld) if (selectedPrice !== 'all') { - const maxPrice = parseInt(selectedPrice) - if (selectedPrice === '10000') { - // High Roller = above $10k - filtered = filtered.filter(item => item.price >= 10000) - } else { - filtered = filtered.filter(item => item.price < maxPrice) - } + const max = parseInt(selectedPrice) + items = selectedPrice === '10000' + ? items.filter(i => i.price >= 10000) + : items.filter(i => i.price < max) } - - // 5. Search if (searchQuery) { const q = searchQuery.toLowerCase() - filtered = filtered.filter(item => item.domain.toLowerCase().includes(q)) + items = items.filter(i => i.domain.toLowerCase().includes(q)) } - // Sort by Pounce Score (highest first) - filtered.sort((a, b) => b.pounceScore - a.pounceScore) + // Sort + items.sort((a, b) => { + const mult = sortDirection === 'asc' ? 1 : -1 + switch (sortField) { + case 'domain': return mult * a.domain.localeCompare(b.domain) + case 'score': return mult * (a.pounceScore - b.pounceScore) + case 'price': return mult * (a.price - b.price) + case 'time': return mult * (parseTimeToSeconds(a.timeLeft) - parseTimeToSeconds(b.timeLeft)) + case 'source': return mult * a.source.localeCompare(b.source) + default: return 0 + } + }) - return filtered - }, [auctions, hideSpam, pounceOnly, selectedTld, selectedPrice, searchQuery]) + return items + }, [auctions, hideSpam, pounceOnly, selectedTld, selectedPrice, searchQuery, sortField, sortDirection]) - // Stats const stats = useMemo(() => ({ total: marketItems.length, highScore: marketItems.filter(i => i.pounceScore >= 80).length, avgScore: marketItems.length > 0 - ? Math.round(marketItems.reduce((sum, i) => sum + i.pounceScore, 0) / marketItems.length) - : 0, + ? Math.round(marketItems.reduce((s, i) => s + i.pounceScore, 0) / marketItems.length) : 0, }), [marketItems]) - // Format currency - const formatPrice = (price: number) => { - if (price >= 1000000) return `$${(price / 1000000).toFixed(1)}M` - if (price >= 1000) return `$${(price / 1000).toFixed(1)}k` - return `$${price.toLocaleString()}` - } + const formatPrice = (p: number) => p >= 1000 ? `$${(p / 1000).toFixed(1)}k` : `$${p.toLocaleString()}` return ( - -
+ +
{/* ================================================================ */} - {/* FILTER BAR */} + {/* HEADER - New Professional Style */} {/* ================================================================ */} -
-
- {/* Filter Icon */} -
- - Filters +
+
+
+
+ +
+

Market Feed

- - {/* Toggle: Hide Spam (Default ON) */} - setHideSpam(!hideSpam)}> - Hide Spam - - - {/* Toggle: Pounce Direct Only */} - setPounceOnly(!pounceOnly)}> - - Pounce Only - - - {/* Divider */} -
- - {/* Dropdown: TLD */} - - - {/* Dropdown: Price */} - - - {/* Search */} -
- setSearchQuery(e.target.value)} - placeholder="Search domains..." - className="w-full px-4 py-2 bg-zinc-800/50 border border-zinc-700/50 rounded-lg - text-sm text-zinc-300 placeholder:text-zinc-600 - focus:outline-none focus:border-emerald-500/50 transition-all" - /> -
- - {/* Refresh */} +

+ {loading ? 'Loading...' : ( + <> + {stats.total} domains + + {stats.highScore} high-score + + Avg score: {stats.avgScore} + + )} +

+
+ +
{/* ================================================================ */} - {/* MARKET TABLE */} + {/* FILTER BAR */} {/* ================================================================ */} -
+
+ - {/* Table Header */} -
-
Domain
-
Score
-
Price / Bid
-
Status
-
Source
-
Action
+ setHideSpam(!hideSpam)}> + + Hide Spam + + + setPounceOnly(!pounceOnly)}> + + Pounce Only + + +
+ + + + +
+ + setSearchQuery(e.target.value)} + placeholder="Search..." + className="w-40 px-3 py-1.5 bg-zinc-800/30 border border-zinc-700/50 rounded + text-xs text-zinc-300 placeholder:text-zinc-600 + focus:outline-none focus:border-emerald-500/50" + /> +
+ + {/* ================================================================ */} + {/* TABLE */} + {/* ================================================================ */} +
+ + {/* Header Row */} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ Action +
- {/* Table Body */} + {/* Body */} {loading ? ( -
- +
+
) : marketItems.length === 0 ? ( -
- -

No domains match your filters

-

Try adjusting your filter settings

+
+ +

No domains match your filters

) : ( -
+
{marketItems.map((item) => (
{/* Domain */}
-
- {item.isPounce && ( - +
+ {item.isPounce && } + {item.domain} + {item.verified && ( + )} -
- {item.domain} - {item.verified && ( - - ✓ Verified - - )} - {/* Mobile: Show score inline */} -
- - -
-
+
+ {/* Mobile info */} +
+ +
- {/* Pounce Score */} -
+ {/* Score */} +
- {/* Price / Bid */} + {/* Price */}
- - {formatPrice(item.price)} - - {item.priceType === 'bid' && ( - (bid) - )} + {formatPrice(item.price)} + {item.priceType === 'bid' && bid} {item.numBids && item.numBids > 0 && ( -

{item.numBids} bids

+

{item.numBids} bids

)}
- {/* Status / Time */} -
+ {/* Status */} +
{/* Source */} -
+
{/* Actions */} -
- {/* Track Button */} +
- - {/* Action Button */} {item.isPounce ? 'Buy' : 'Bid'} - +
@@ -608,18 +638,11 @@ export default function MarketPage() { )}
- {/* ================================================================ */} - {/* FOOTER INFO */} - {/* ================================================================ */} -
- - Showing {marketItems.length} of {auctions.length} total listings - - - Data from GoDaddy, Sedo, NameJet, DropCatch • Updated every 15 minutes - + {/* Footer */} +
+ {marketItems.length} of {auctions.length} listings + GoDaddy • Sedo • NameJet • DropCatch
-
)