From 6ac6577fb29efea5d2296f40d69044ba02849caf Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Wed, 10 Dec 2025 22:45:05 +0100 Subject: [PATCH] feat: MARKET - Add sortable columns + new Live Feed header style Changes: - Sortable columns: Domain, Score, Price, Time Left, Source - Click column header to sort (asc/desc toggle) - New header: 'Live Market Feed' with live indicator - Quick stats pills: total listings, high score count, ending soon - Visual sort indicators (chevron up/down) - Default sort: Score descending --- backend/app/services/email_service.py | 2 +- frontend/src/app/terminal/market/page.tsx | 606 +++++++++++++--------- frontend/src/app/terminal/radar/page.tsx | 2 +- 3 files changed, 374 insertions(+), 236 deletions(-) diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 0f5b460..f3be8bf 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -67,7 +67,7 @@ BASE_TEMPLATE = """
- {{ content }} + {{ content }}
diff --git a/frontend/src/app/terminal/market/page.tsx b/frontend/src/app/terminal/market/page.tsx index c691c36..f72f221 100644 --- a/frontend/src/app/terminal/market/page.tsx +++ b/frontend/src/app/terminal/market/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState, useMemo, useCallback } from 'react' import { useStore } from '@/lib/store' import { api } from '@/lib/api' import { TerminalLayout } from '@/components/TerminalLayout' -import { +import { ExternalLink, Loader2, Diamond, @@ -18,8 +18,9 @@ import { TrendingUp, RefreshCw, ArrowUpDown, - Sparkles, - BarChart3, + Activity, + Flame, + Clock, } from 'lucide-react' import clsx from 'clsx' @@ -72,7 +73,7 @@ function calculatePounceScore(domain: string, tld: string, numBids?: number, age let score = 50 const name = domain.split('.')[0] - // Length bonus + // Length bonus (shorter = better) if (name.length <= 3) score += 30 else if (name.length === 4) score += 25 else if (name.length === 5) score += 20 @@ -90,7 +91,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 + // Activity bonus (more bids = more valuable) if (numBids && numBids >= 20) score += 8 else if (numBids && numBids >= 10) score += 5 else if (numBids && numBids >= 5) score += 2 @@ -116,16 +117,13 @@ function isSpamDomain(domain: string, tld: string): boolean { // 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 } @@ -133,7 +131,7 @@ function parseTimeToSeconds(timeStr?: string): number { // COMPONENTS // ============================================================================ -// Score Badge +// Score Badge with color coding function ScoreBadge({ score }: { score: number }) { const color = score >= 80 ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' @@ -142,12 +140,12 @@ function ScoreBadge({ score }: { score: number }) { : 'bg-red-500/20 text-red-400 border-red-500/30' return ( - {score} - + ) } @@ -155,22 +153,25 @@ function ScoreBadge({ score }: { score: number }) { function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }) { if (isPounce) { return ( -
- - Pounce +
+ + Pounce
) } const colors: Record = { - GoDaddy: 'text-orange-400/80', - Sedo: 'text-blue-400/80', - NameJet: 'text-purple-400/80', - DropCatch: 'text-cyan-400/80', + 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', } return ( - + {source} ) @@ -180,9 +181,9 @@ function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean } function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; timeLeft?: string }) { if (status === 'instant') { return ( -
- - Instant +
+ + Instant
) } @@ -192,16 +193,18 @@ function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; time return (
{timeLeft} @@ -209,15 +212,70 @@ function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; time ) } -// Sortable Column Header -function SortHeader({ - label, - field, - currentSort, - currentDirection, - onSort, - align = 'left' +// Toggle Button +function ToggleButton({ + active, + onClick, + children }: { + active: boolean + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +// Dropdown Select +function DropdownSelect({ + value, + onChange, + options, +}: { + value: string + onChange: (v: string) => void + options: { value: string; label: string }[] +}) { + return ( +
+ + +
+ ) +} + +// Sortable Column Header +function SortableHeader({ + label, + field, + currentSort, + currentDirection, + onSort, + align = 'left', +}: { label: string field: SortField currentSort: SortField @@ -231,64 +289,26 @@ function SortHeader({ ) } -// 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 ( - - ) -} - // ============================================================================ // MAIN COMPONENT // ============================================================================ @@ -296,26 +316,27 @@ function Dropdown({ value, onChange, options }: { value: string; onChange: (v: s export default function MarketPage() { const { subscription } = useStore() - // Data + // Data State const [auctions, setAuctions] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) - // Filters + // Filter State const [hideSpam, setHideSpam] = useState(true) const [pounceOnly, setPounceOnly] = useState(false) const [selectedTld, setSelectedTld] = useState('all') const [selectedPrice, setSelectedPrice] = useState('all') const [searchQuery, setSearchQuery] = useState('') - // Sorting + // Sort State const [sortField, setSortField] = useState('score') const [sortDirection, setSortDirection] = useState('desc') - // Watchlist + // Watchlist State const [trackedDomains, setTrackedDomains] = useState>(new Set()) const [trackingInProgress, setTrackingInProgress] = useState(null) + // Options const TLD_OPTIONS = [ { value: 'all', label: 'All TLDs' }, { value: 'com', label: '.com' }, @@ -328,7 +349,7 @@ export default function MarketPage() { const PRICE_OPTIONS = [ { value: 'all', label: 'Any Price' }, { value: '100', label: '< $100' }, - { value: '1000', label: '< $1k' }, + { value: '1000', label: '< $1,000' }, { value: '10000', label: 'High Roller' }, ] @@ -339,7 +360,7 @@ export default function MarketPage() { const data = await api.getAuctions() setAuctions(data.auctions || []) } catch (error) { - console.error('Failed to load:', error) + console.error('Failed to load market data:', error) } finally { setLoading(false) } @@ -360,234 +381,339 @@ export default function MarketPage() { setSortDirection(d => d === 'asc' ? 'desc' : 'asc') } else { setSortField(field) - setSortDirection(field === 'domain' || field === 'source' ? 'asc' : 'desc') + // Default direction based on field type + setSortDirection(field === 'score' || field === 'price' ? 'desc' : 'asc') } }, [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:', error) + console.error('Failed to track:', error) } finally { setTrackingInProgress(null) } }, [trackedDomains, trackingInProgress]) - // Process Data + // Transform and Filter Data const marketItems = useMemo(() => { - 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, + // 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, priceType: 'bid' as const, status: 'auction' as const, - timeLeft: a.time_remaining, - endTime: a.end_time, - source: a.platform as any, + timeLeft: auction.time_remaining, + endTime: auction.end_time, + source: auction.platform as any, isPounce: false, - affiliateUrl: a.affiliate_url, - tld: a.tld, - numBids: a.num_bids, + affiliateUrl: auction.affiliate_url, + tld: auction.tld, + numBids: auction.num_bids, })) - // 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 max = parseInt(selectedPrice) - items = selectedPrice === '10000' - ? items.filter(i => i.price >= 10000) - : items.filter(i => i.price < max) - } - if (searchQuery) { - const q = searchQuery.toLowerCase() - items = items.filter(i => i.domain.toLowerCase().includes(q)) + // Apply Filters + let filtered = items + + if (hideSpam) { + filtered = filtered.filter(item => !isSpamDomain(item.domain, item.tld)) } - // Sort - items.sort((a, b) => { - const mult = sortDirection === 'asc' ? 1 : -1 + if (pounceOnly) { + filtered = filtered.filter(item => item.isPounce) + } + + if (selectedTld !== 'all') { + filtered = filtered.filter(item => item.tld === selectedTld) + } + + if (selectedPrice !== 'all') { + const maxPrice = parseInt(selectedPrice) + if (selectedPrice === '10000') { + filtered = filtered.filter(item => item.price >= 10000) + } else { + filtered = filtered.filter(item => item.price < maxPrice) + } + } + + if (searchQuery) { + const q = searchQuery.toLowerCase() + filtered = filtered.filter(item => item.domain.toLowerCase().includes(q)) + } + + // Apply Sort + const mult = sortDirection === 'asc' ? 1 : -1 + filtered.sort((a, b) => { 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 + 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 items + return filtered }, [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((s, i) => s + i.pounceScore, 0) / marketItems.length) : 0, + endingSoon: marketItems.filter(i => { + const seconds = parseTimeToSeconds(i.timeLeft) + return seconds < 3600 // Less than 1 hour + }).length, }), [marketItems]) - const formatPrice = (p: number) => p >= 1000 ? `$${(p / 1000).toFixed(1)}k` : `$${p.toLocaleString()}` + // 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()}` + } return ( -
+
{/* ================================================================ */} - {/* HEADER - New Professional Style */} + {/* HEADER - Live Feed Style */} {/* ================================================================ */} -
-
-
-
- +
+ {/* Left: Title with Live Indicator */} +
+
+
+ + +
+
+

Live Market Feed

+

Updated in real-time

-

Market Feed

-

- {loading ? 'Loading...' : ( - <> - {stats.total} domains - - {stats.highScore} high-score - - Avg score: {stats.avgScore} - + + {/* Quick Stats Pills */} +

+
+ {stats.total} + listings +
+
+ + {stats.highScore} + high score +
+ {stats.endingSoon > 0 && ( +
+ + {stats.endingSoon} + ending soon +
)} -

-
- -
- +
+ + {/* Right: Refresh */} +
{/* ================================================================ */} {/* FILTER BAR */} {/* ================================================================ */} -
- - - 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" - /> +
+
+
+ + Filters +
+ + setHideSpam(!hideSpam)}> + Hide Spam + + + setPounceOnly(!pounceOnly)}> + + Pounce Only + + +
+ + + + + +
+ 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" + /> +
+
{/* ================================================================ */} - {/* TABLE */} + {/* MARKET TABLE */} {/* ================================================================ */} -
+
- {/* Header Row */} -
+ {/* Table Header - Sortable */} +
- +
- +
- +
- +
- +
- Action + Action
- {/* Body */} + {/* Table Body */} {loading ? ( -
- +
+
) : marketItems.length === 0 ? ( -
- -

No domains match your filters

+
+ +

No domains match your filters

+

Try adjusting your filter settings

) : ( -
+
{marketItems.map((item) => (
{/* Domain */}
-
- {item.isPounce && } - {item.domain} - {item.verified && ( - +
+ {item.isPounce && ( + )} -
- {/* Mobile info */} -
- - +
+ {item.domain} + {item.verified && ( + + ✓ Verified + + )} + {/* Mobile: Show score inline */} +
+ + +
+
- {/* Score */} + {/* Pounce Score */}
- {/* Price */} + {/* Price / Bid */}
- {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 */} + {/* Status / Time */}
@@ -598,38 +724,42 @@ export default function MarketPage() {
{/* Actions */} -
+
+ {/* Track Button */} + + {/* Action Button */} {item.isPounce ? 'Buy' : 'Bid'} - +
@@ -638,11 +768,19 @@ export default function MarketPage() { )}
- {/* Footer */} -
- {marketItems.length} of {auctions.length} listings - GoDaddy • Sedo • NameJet • DropCatch + {/* ================================================================ */} + {/* FOOTER INFO */} + {/* ================================================================ */} +
+ + Showing {marketItems.length} of {auctions.length} total listings + + + + Data from GoDaddy, Sedo, NameJet, DropCatch +
+
) diff --git a/frontend/src/app/terminal/radar/page.tsx b/frontend/src/app/terminal/radar/page.tsx index be78530..b4eb049 100644 --- a/frontend/src/app/terminal/radar/page.tsx +++ b/frontend/src/app/terminal/radar/page.tsx @@ -225,7 +225,7 @@ export default function RadarPage() { 0 ? `${availableDomains.length} alerts` : undefined} icon={Eye} accent={availableDomains.length > 0}