From 718a7d64e5cc61474b0230f6639c544ed758c94b Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Fri, 12 Dec 2025 22:34:39 +0100 Subject: [PATCH] Auto health check on add, Market page redesign --- frontend/src/app/terminal/market/page.tsx | 840 +++++++------------ frontend/src/app/terminal/watchlist/page.tsx | 14 +- 2 files changed, 332 insertions(+), 522 deletions(-) diff --git a/frontend/src/app/terminal/market/page.tsx b/frontend/src/app/terminal/market/page.tsx index 8ad547c..c800ebc 100644 --- a/frontend/src/app/terminal/market/page.tsx +++ b/frontend/src/app/terminal/market/page.tsx @@ -3,37 +3,27 @@ import { useEffect, useState, useMemo, useCallback, memo } from 'react' import { useStore } from '@/lib/store' import { api } from '@/lib/api' -import { TerminalLayout } from '@/components/TerminalLayout' +import { CommandCenterLayout } from '@/components/CommandCenterLayout' import { ExternalLink, Loader2, Diamond, - Timer, Zap, - Filter, ChevronDown, - ChevronUp, Plus, Check, TrendingUp, RefreshCw, - ArrowUpDown, - Activity, - Flame, Clock, Search, - LayoutGrid, - List, - SlidersHorizontal, - MoreHorizontal, Eye, - Info, ShieldCheck, - Sparkles, Store, - DollarSign, Gavel, - Ban + Ban, + Activity, + Target, + ArrowRight } from 'lucide-react' import clsx from 'clsx' import Link from 'next/link' @@ -69,25 +59,9 @@ type SourceFilter = 'all' | 'pounce' | 'external' type PriceRange = 'all' | 'low' | 'mid' | 'high' // ============================================================================ -// HELPER FUNCTIONS +// HELPERS // ============================================================================ -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 -} - -/** - * Calculate time remaining from end_time ISO string (UTC). - * Returns human-readable string like "2h 15m" or "Ended". - */ function calcTimeRemaining(endTimeIso?: string): string { if (!endTimeIso) return 'N/A' const end = new Date(endTimeIso).getTime() @@ -107,9 +81,6 @@ function calcTimeRemaining(endTimeIso?: string): string { return '< 1m' } -/** - * Get seconds until end from ISO string (for sorting/urgency). - */ function getSecondsUntilEnd(endTimeIso?: string): number { if (!endTimeIso) return Infinity const diff = new Date(endTimeIso).getTime() - Date.now() @@ -125,180 +96,10 @@ function formatPrice(price: number, currency = 'USD'): string { } function isSpam(domain: string): boolean { - // Check for hyphens or numbers in the name part (excluding TLD) const name = domain.split('.')[0] return /[-\d]/.test(name) } -// ============================================================================ -// COMPONENTS -// ============================================================================ - -// Tooltip -const Tooltip = memo(({ children, content }: { children: React.ReactNode; content: string }) => ( -
- {children} -
- {content} -
-
-
-)) -Tooltip.displayName = 'Tooltip' - -// Stat Card (Matched to Watchlist Page) -const StatCard = memo(({ - label, - value, - subValue, - icon: Icon, - highlight -}: { - label: string - value: string | number - subValue?: string - icon: any - highlight?: boolean -}) => ( -
-
- -
-
-
- - {label} -
-
- {value} - {subValue && {subValue}} -
- {highlight && ( -
- ● LIVE -
- )} -
-
-)) -StatCard.displayName = 'StatCard' - -// Score Ring -const ScoreDisplay = memo(({ score, mobile = false }: { score: number; mobile?: boolean }) => { - const color = score >= 80 ? 'text-emerald-500' : score >= 50 ? 'text-amber-500' : 'text-zinc-600' - - if (mobile) { - return ( -
= 80 ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" : - score >= 50 ? "bg-amber-500/10 text-amber-400 border-amber-500/20" : - "bg-zinc-800 text-zinc-400 border-zinc-700" - )}> - {score} -
- ) - } - - const size = 36 - const strokeWidth = 3 - const radius = (size - strokeWidth) / 2 - const circumference = radius * 2 * Math.PI - const offset = circumference - (score / 100) * circumference - - return ( - -
- - - - - = 80 ? 'text-emerald-400' : 'text-zinc-400')}> - {score} - -
-
- ) -}) -ScoreDisplay.displayName = 'ScoreDisplay' - -// Filter Toggle -const FilterToggle = memo(({ active, onClick, label, icon: Icon }: { - active: boolean - onClick: () => void - label: string - icon?: any -}) => ( - -)) -FilterToggle.displayName = 'FilterToggle' - -// Sort Header -const SortableHeader = memo(({ - label, field, currentSort, currentDirection, onSort, align = 'left', tooltip -}: { - label: string - field: SortField - currentSort: SortField - currentDirection: SortDirection - onSort: (field: SortField) => void - align?: 'left'|'center'|'right' - tooltip?: string -}) => { - const isActive = currentSort === field - return ( -
- - {tooltip && ( - - - - )} -
- ) -}) -SortableHeader.displayName = 'SortableHeader' - // ============================================================================ // MAIN PAGE // ============================================================================ @@ -306,29 +107,23 @@ SortableHeader.displayName = 'SortableHeader' export default function MarketPage() { const { subscription } = useStore() - // Data const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [stats, setStats] = useState({ total: 0, pounceCount: 0, auctionCount: 0, highScore: 0 }) - // Filters const [sourceFilter, setSourceFilter] = useState('all') const [searchQuery, setSearchQuery] = useState('') const [priceRange, setPriceRange] = useState('all') - const [verifiedOnly, setVerifiedOnly] = useState(false) const [hideSpam, setHideSpam] = useState(true) const [tldFilter, setTldFilter] = useState('all') - // Sort const [sortField, setSortField] = useState('score') const [sortDirection, setSortDirection] = useState('desc') - // Watchlist const [trackedDomains, setTrackedDomains] = useState>(new Set()) const [trackingInProgress, setTrackingInProgress] = useState(null) - // Load data const loadData = useCallback(async () => { setLoading(true) try { @@ -338,7 +133,6 @@ export default function MarketPage() { tld: tldFilter === 'all' ? undefined : tldFilter, minPrice: priceRange === 'low' ? undefined : priceRange === 'mid' ? 100 : priceRange === 'high' ? 1000 : undefined, maxPrice: priceRange === 'low' ? 100 : priceRange === 'mid' ? 1000 : undefined, - verifiedOnly, sortBy: sortField === 'score' ? 'score' : sortField === 'price' ? (sortDirection === 'asc' ? 'price_asc' : 'price_desc') : sortField === 'time' ? 'time' : 'newest', @@ -358,7 +152,7 @@ export default function MarketPage() { } finally { setLoading(false) } - }, [sourceFilter, searchQuery, priceRange, verifiedOnly, sortField, sortDirection, tldFilter]) + }, [sourceFilter, searchQuery, priceRange, sortField, sortDirection, tldFilter]) useEffect(() => { loadData() }, [loadData]) @@ -390,36 +184,29 @@ export default function MarketPage() { } }, [trackedDomains, trackingInProgress]) - // Client-side filtering for immediate UI feedback & SPAM FILTER const filteredItems = useMemo(() => { let filtered = items - // Hard safety: never show ended auctions client-side. - // (Server already filters, this is a guardrail against any drift/cache.) const nowMs = Date.now() filtered = filtered.filter(item => { if (item.status !== 'auction') return true if (!item.end_time) return true const t = Date.parse(item.end_time) if (Number.isNaN(t)) return true - return t > (nowMs - 2000) // 2s grace + return t > (nowMs - 2000) }) - // Additional client-side search if (searchQuery && !loading) { const query = searchQuery.toLowerCase() filtered = filtered.filter(item => item.domain.toLowerCase().includes(query)) } - // Hide Spam (Client-side) if (hideSpam) { filtered = filtered.filter(item => !isSpam(item.domain)) } - // Sort const mult = sortDirection === 'asc' ? 1 : -1 filtered = [...filtered].sort((a, b) => { - // Pounce Direct always appears first within same score tier if score sort if (sortField === 'score' && a.is_pounce !== b.is_pounce) { return a.is_pounce ? -1 : 1 } @@ -438,311 +225,324 @@ export default function MarketPage() { }, [items, searchQuery, sortField, sortDirection, loading, hideSpam]) return ( - -
- - {/* Ambient Background Glow (Matched to Watchlist) */} -
-
-
-
- -
- - {/* Header Section (Matched to Watchlist) */} -
-
-
-
-

Market Feed

-
-

- Real-time auctions from Pounce Direct, GoDaddy, Sedo, and DropCatch. -

+ + {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* HEADER */} + {/* ═══════════════════════════════════════════════════════════════════════ */} +
+
+
+
+ + Live Auctions
- {/* Quick Stats Pills */} -
-
- - {stats.pounceCount} Exclusive -
-
- - {stats.auctionCount} External -
-
+

+ Market + {stats.total} +

- - {/* Metric Grid (Matched to Watchlist) */} -
- - - - -
- - {/* Control Bar (Matched to Watchlist) */} -
- {/* Filter Pills */} -
- setHideSpam(!hideSpam)} - label="Hide Spam" - icon={Ban} - /> -
- setSourceFilter(f => f === 'pounce' ? 'all' : 'pounce')} - label="Pounce Only" - icon={Diamond} - /> - - {/* TLD Dropdown (Simulated with select) */} -
- - -
- -
- - setPriceRange(p => p === 'low' ? 'all' : 'low')} - label="< $100" - /> - setPriceRange(p => p === 'mid' ? 'all' : 'mid')} - label="< $1k" - /> - setPriceRange(p => p === 'high' ? 'all' : 'high')} - label="High Roller" - /> + +
+
+
{stats.pounceCount}
+
Pounce Direct
- - {/* Refresh Button (Mobile) */} - - - {/* Search Filter */} -
- - setSearchQuery(e.target.value)} - placeholder="Search domains..." - className="w-full bg-black/50 border border-white/10 rounded-lg pl-9 pr-4 py-2 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-white/20 transition-all" - /> +
+
{stats.auctionCount}
+
External
-
- - {/* DATA GRID */} -
- {/* Unified Table Header */} -
-
- -
-
- -
-
- -
-
- -
-
Action
+
+
{stats.highScore}
+
Score 80+
- - {loading ? ( -
- -

Scanning live markets...

-
- ) : filteredItems.length === 0 ? ( -
-
- -
-

No matches found

-

- Try adjusting your filters or search query. -

-
- ) : ( -
- {filteredItems.map((item) => { - const timeLeftSec = getSecondsUntilEnd(item.end_time) - const isUrgent = timeLeftSec > 0 && timeLeftSec < 3600 - const isPounce = item.is_pounce - const displayTime = item.status === 'auction' ? calcTimeRemaining(item.end_time) : null - - return ( -
- {/* Domain */} -
-
- {isPounce ? ( -
-
- -
-
- ) : ( -
- {item.source.substring(0, 2).toUpperCase()} -
- )} - -
-
- {item.domain} -
-
- {item.source} - {isPounce && item.verified && ( - <> - - - - Verified - - - )} - {!isPounce && item.num_bids ? `• ${item.num_bids} bids` : ''} -
-
-
-
- - {/* Score */} -
- -
- - {/* Price */} -
-
- {formatPrice(item.price, item.currency)} -
-
- {item.price_type === 'bid' ? 'Current Bid' : 'Buy Now'} -
-
- - {/* Status/Time */} -
- {isPounce ? ( -
- - Instant -
- ) : ( -
- - {displayTime || 'N/A'} -
- )} -
- - {/* Actions */} -
- - - - - - {isPounce ? 'Buy' : 'Bid'} - {isPounce ? : } - -
-
- ) - })} -
- )}
-
- - ) -} +
+ {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* FILTERS */} + {/* ═══════════════════════════════════════════════════════════════════════ */} +
+
+ {/* Source Filters */} + + + +
+ + {/* TLD Filter */} + + +
+ + {/* Price Filters */} + {[ + { value: 'low', label: '< $100' }, + { value: 'mid', label: '< $1k' }, + { value: 'high', label: '$1k+' }, + ].map((item) => ( + + ))} + +
+ + {/* Hide Spam */} + + + {/* Search - Right aligned */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search domains..." + className="bg-[#050505] border border-white/10 pl-9 pr-4 py-2 text-sm text-white placeholder:text-white/25 outline-none focus:border-accent/40 w-48 lg:w-64" + /> +
+ + {/* Refresh */} + +
+
+ + {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* TABLE */} + {/* ═══════════════════════════════════════════════════════════════════════ */} +
+ {loading ? ( +
+ +
+ ) : filteredItems.length === 0 ? ( +
+
+ +
+

No domains found

+

Try adjusting your filters

+
+ ) : ( +
+ {/* Table Header */} +
+
Domain
+
Score
+
Price
+
Time
+
Actions
+
+ + {/* Rows */} + {filteredItems.map((item) => { + const timeLeftSec = getSecondsUntilEnd(item.end_time) + const isUrgent = timeLeftSec > 0 && timeLeftSec < 3600 + const isPounce = item.is_pounce + const displayTime = item.status === 'auction' ? calcTimeRemaining(item.end_time) : null + const isTracked = trackedDomains.has(item.domain) + const isTracking = trackingInProgress === item.domain + + return ( +
+ {/* Mobile */} +
+
+
+ {isPounce ? ( + + ) : ( + {item.source.substring(0, 3)} + )} + {item.domain} +
+ + {formatPrice(item.price)} + +
+
+ Score: {item.pounce_score} + {displayTime || 'Instant'} +
+
+ + {/* Desktop */} +
+ {/* Domain */} +
+ {isPounce ? ( +
+ +
+ ) : ( +
+ {item.source.substring(0, 2).toUpperCase()} +
+ )} +
+
{item.domain}
+
+ {item.source} + {isPounce && item.verified && ( + <> + · + + + Verified + + + )} + {item.num_bids ? <>·{item.num_bids} bids : null} +
+
+
+ + {/* Score */} +
+ = 80 ? "text-accent bg-accent/10" : + item.pounce_score >= 50 ? "text-amber-400 bg-amber-400/10" : + "text-white/40 bg-white/5" + )}> + {item.pounce_score} + +
+ + {/* Price */} +
+
+ {formatPrice(item.price)} +
+
+ {item.price_type === 'bid' ? 'Bid' : 'Buy Now'} +
+
+ + {/* Time */} +
+ {isPounce ? ( + + + Instant + + ) : ( + + {displayTime || 'N/A'} + + )} +
+ + {/* Actions */} +
+ + + + {isPounce ? 'Buy' : 'Bid'} + {!isPounce && } + +
+
+
+ ) + })} +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/app/terminal/watchlist/page.tsx b/frontend/src/app/terminal/watchlist/page.tsx index e40acdc..007f533 100755 --- a/frontend/src/app/terminal/watchlist/page.tsx +++ b/frontend/src/app/terminal/watchlist/page.tsx @@ -120,9 +120,19 @@ export default function WatchlistPage() { if (!newDomain.trim()) return setAdding(true) try { - await addDomain(newDomain.trim()) - showToast(`Target locked: ${newDomain.trim()}`, 'success') + const result = await addDomain(newDomain.trim()) + showToast(`Added: ${newDomain.trim()}`, 'success') setNewDomain('') + + // Trigger health check for the newly added domain + if (result?.id) { + setLoadingHealth(prev => ({ ...prev, [result.id]: true })) + try { + const report = await api.getDomainHealth(result.id, { refresh: true }) + setHealthReports(prev => ({ ...prev, [result.id]: report })) + } catch {} + finally { setLoadingHealth(prev => ({ ...prev, [result.id]: false })) } + } } catch (err: any) { showToast(err.message || 'Failed', 'error') } finally {