From 7c536b32cecead7b96a73c465eb5507f8a896461 Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Sat, 13 Dec 2025 13:36:42 +0100 Subject: [PATCH] Radar: Added watchlist preview, Market: Native app mobile design --- frontend/src/app/terminal/market/page.tsx | 1228 ++++++++++++++------- frontend/src/app/terminal/radar/page.tsx | 72 ++ 2 files changed, 874 insertions(+), 426 deletions(-) diff --git a/frontend/src/app/terminal/market/page.tsx b/frontend/src/app/terminal/market/page.tsx index e475f73..58f73d7 100644 --- a/frontend/src/app/terminal/market/page.tsx +++ b/frontend/src/app/terminal/market/page.tsx @@ -1,19 +1,17 @@ 'use client' -import { useEffect, useState, useMemo, useCallback, memo } from 'react' +import { useEffect, useState, useMemo, useCallback } from 'react' import { useStore } from '@/lib/store' import { api } from '@/lib/api' -import { CommandCenterLayout } from '@/components/CommandCenterLayout' +import { Sidebar } from '@/components/Sidebar' +import { Toast, useToast } from '@/components/Toast' import { ExternalLink, Loader2, Diamond, Zap, - ChevronDown, ChevronLeft, ChevronRight, - Plus, - Check, TrendingUp, RefreshCw, Clock, @@ -21,15 +19,22 @@ import { Eye, EyeOff, ShieldCheck, - Store, Gavel, Ban, - Activity, Target, - ArrowRight + X, + Menu, + Settings, + Shield, + LogOut, + Crown, + Sparkles, + Coins, + Tag } from 'lucide-react' import clsx from 'clsx' import Link from 'next/link' +import Image from 'next/image' // ============================================================================ // TYPES @@ -56,8 +61,6 @@ interface MarketItem { pounce_score: number } -type SortField = 'domain' | 'score' | 'price' | 'time' | 'source' -type SortDirection = 'asc' | 'desc' type SourceFilter = 'all' | 'pounce' | 'external' type PriceRange = 'all' | 'low' | 'mid' | 'high' @@ -108,7 +111,8 @@ function isSpam(domain: string): boolean { // ============================================================================ export default function MarketPage() { - const { subscription } = useStore() + const { subscription, user, logout } = useStore() + const { toast, showToast, hideToast } = useToast() const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) @@ -120,9 +124,7 @@ export default function MarketPage() { const [priceRange, setPriceRange] = useState('all') const [hideSpam, setHideSpam] = useState(true) const [tldFilter, setTldFilter] = useState('all') - - const [sortField, setSortField] = useState('score') - const [sortDirection, setSortDirection] = useState('desc') + const [searchFocused, setSearchFocused] = useState(false) const [trackedDomains, setTrackedDomains] = useState>(new Set()) const [trackingInProgress, setTrackingInProgress] = useState(null) @@ -131,6 +133,9 @@ export default function MarketPage() { const [page, setPage] = useState(1) const [totalPages, setTotalPages] = useState(1) const ITEMS_PER_PAGE = 50 + + // Mobile Menu + const [menuOpen, setMenuOpen] = useState(false) const loadData = useCallback(async (currentPage = 1) => { setLoading(true) @@ -141,9 +146,7 @@ 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, - sortBy: sortField === 'score' ? 'score' : - sortField === 'price' ? (sortDirection === 'asc' ? 'price_asc' : 'price_desc') : - sortField === 'time' ? 'time' : 'newest', + sortBy: 'newest', limit: ITEMS_PER_PAGE, offset: (currentPage - 1) * ITEMS_PER_PAGE }) @@ -162,7 +165,7 @@ export default function MarketPage() { } finally { setLoading(false) } - }, [sourceFilter, searchQuery, priceRange, sortField, sortDirection, tldFilter]) + }, [sourceFilter, searchQuery, priceRange, tldFilter]) useEffect(() => { setPage(1) @@ -180,15 +183,6 @@ 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 === 'score' || field === 'price' ? 'desc' : 'asc') - } - }, [sortField]) - // Load user's tracked domains on mount useEffect(() => { const loadTrackedDomains = async () => { @@ -209,7 +203,6 @@ export default function MarketPage() { try { if (trackedDomains.has(domain)) { - // Find and delete the domain const result = await api.getDomains(1, 100) const domainToDelete = result.domains.find(d => d.name === domain) if (domainToDelete) { @@ -219,18 +212,19 @@ export default function MarketPage() { next.delete(domain) return next }) + showToast(`Removed: ${domain}`, 'success') } } else { - // Add the domain await api.addDomain(domain) setTrackedDomains(prev => new Set([...Array.from(prev), domain])) + showToast(`Tracking: ${domain}`, 'success') } - } catch (error) { - console.error(error) + } catch (error: any) { + showToast(error.message || 'Failed', 'error') } finally { setTrackingInProgress(null) } - }, [trackedDomains, trackingInProgress]) + }, [trackedDomains, trackingInProgress, showToast]) const filteredItems = useMemo(() => { let filtered = items @@ -252,431 +246,813 @@ export default function MarketPage() { if (hideSpam) { filtered = filtered.filter(item => !isSpam(item.domain)) } - - const mult = sortDirection === 'asc' ? 1 : -1 - filtered = [...filtered].sort((a, b) => { - if (sortField === 'score' && a.is_pounce !== b.is_pounce) { - return a.is_pounce ? -1 : 1 - } - - switch (sortField) { - case 'domain': return mult * a.domain.localeCompare(b.domain) - case 'score': return mult * (a.pounce_score - b.pounce_score) - case 'price': return mult * (a.price - b.price) - case 'time': return mult * (getSecondsUntilEnd(a.end_time) - getSecondsUntilEnd(b.end_time)) - case 'source': return mult * a.source.localeCompare(b.source) - default: return 0 - } - }) return filtered - }, [items, searchQuery, sortField, sortDirection, loading, hideSpam]) + }, [items, searchQuery, loading, hideSpam]) + + // Mobile Nav + const mobileNavItems = [ + { href: '/terminal/radar', label: 'Radar', icon: Target, active: false }, + { href: '/terminal/market', label: 'Market', icon: Gavel, active: true }, + { href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false }, + { href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false }, + ] + + const tierName = subscription?.tier_name || subscription?.tier || 'Scout' + const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap + + const drawerNavSections = [ + { + title: 'Discover', + items: [ + { href: '/terminal/radar', label: 'Radar', icon: Target }, + { href: '/terminal/market', label: 'Market', icon: Gavel }, + { href: '/terminal/intel', label: 'Intel', icon: TrendingUp }, + ] + }, + { + title: 'Manage', + items: [ + { href: '/terminal/watchlist', label: 'Watchlist', icon: Eye }, + { href: '/terminal/sniper', label: 'Sniper', icon: Target }, + ] + }, + { + title: 'Monetize', + items: [ + { href: '/terminal/yield', label: 'Yield', icon: Coins, isNew: true }, + { href: '/terminal/listing', label: 'For Sale', icon: Tag }, + ] + } + ] return ( - - {/* ═══════════════════════════════════════════════════════════════════════ */} - {/* HEADER */} - {/* ═══════════════════════════════════════════════════════════════════════ */} -
-
-
-
- - Live Auctions +
+ {/* Desktop Sidebar */} +
+ +
+ + {/* Main Content */} +
+ + {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* MOBILE HEADER */} + {/* ═══════════════════════════════════════════════════════════════════════ */} +
+
+
+
+
+ +
+
+
+

Market

+

Live Auctions

+
-

- Market - {stats.total} -

-
- -
-
-
{stats.pounceCount}
-
Pounce Direct
-
-
-
{stats.auctionCount}
-
External
-
-
-
{stats.highScore}
-
Score 80+
+
+
+
{stats.total}
+
+
-
-
+ - {/* ═══════════════════════════════════════════════════════════════════════ */} - {/* FILTERS */} - {/* ═══════════════════════════════════════════════════════════════════════ */} -
-
- {/* Source Filters */} - - - -
- - {/* TLD Filter */} - - -
- - {/* Price Filters */} - {[ - { value: 'low', label: '< $100' }, - { value: 'mid', label: '< $1k' }, - { value: 'high', label: '$1k+' }, - ].map((item) => ( + {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* SEARCH - Mobile */} + {/* ═══════════════════════════════════════════════════════════════════════ */} +
+
+
+ + setSearchQuery(e.target.value)} + onFocus={() => setSearchFocused(true)} + onBlur={() => setSearchFocused(false)} + placeholder="Search auctions..." + className="flex-1 bg-transparent px-3 py-3.5 text-base text-white placeholder:text-white/20 outline-none font-medium" + /> + {searchQuery && ( + + )} +
+
+
+ + {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* FILTERS - Mobile Horizontal Scroll */} + {/* ═══════════════════════════════════════════════════════════════════════ */} +
+
- ))} - -
- - {/* 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" - /> + + + + {['com', 'ai', 'io', 'net'].map((tld) => ( + + ))} + + {[ + { value: 'low', label: '< $100' }, + { value: 'mid', label: '< $1k' }, + { value: 'high', label: '$1k+' }, + ].map((item) => ( + + ))}
- - {/* Refresh */} - -
-
+
- {/* ═══════════════════════════════════════════════════════════════════════ */} - {/* TABLE */} - {/* ═══════════════════════════════════════════════════════════════════════ */} -
- {loading ? ( -
- -
- ) : filteredItems.length === 0 ? ( -
-
- -
-

No domains found

-

Try adjusting your filters

-
- ) : ( -
- {/* Table Header */} -
-
Domain
-
Score
-
Price
-
Time
-
Actions
+ {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* DESKTOP HEADER */} + {/* ═══════════════════════════════════════════════════════════════════════ */} +
+
+
+
+ + Live Auctions +
+ +

+ Market + {stats.total} +

- {/* 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'} -
- {/* Mobile Actions */} -
- - - {isPounce ? 'Buy' : 'Bid'} - {!isPounce && } - -
-
- - {/* 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 && } - -
-
-
- ) - })} -
- )} - - {/* Pagination */} - {!loading && totalPages > 1 && ( -
-
- Page {page} of {totalPages} · {stats.total} domains +
+
+
{stats.pounceCount}
+
Pounce Direct
+
+
+
{stats.auctionCount}
+
External
+
+
+
{stats.highScore}
+
Score 80+
+
-
+
+
+ + {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* DESKTOP FILTERS */} + {/* ═══════════════════════════════════════════════════════════════════════ */} +
+
+ + + +
+ + + +
+ + {[ + { value: 'low', label: '< $100' }, + { value: 'mid', label: '< $1k' }, + { value: 'high', label: '$1k+' }, + ].map((item) => ( - - {/* Page Numbers */} -
- {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { - let pageNum: number - if (totalPages <= 5) { - pageNum = i + 1 - } else if (page <= 3) { - pageNum = i + 1 - } else if (page >= totalPages - 2) { - pageNum = totalPages - 4 + i - } else { - pageNum = page - 2 + i - } - + ))} + +
+ + + +
+
+ + 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" + /> +
+ + +
+
+ + {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* CONTENT */} + {/* ═══════════════════════════════════════════════════════════════════════ */} +
+ {loading ? ( +
+ +
+ ) : filteredItems.length === 0 ? ( +
+
+ +
+

No domains found

+

Try adjusting your filters

+
+ ) : ( + <> + {/* Mobile Cards */} +
+ {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 ( - +
+
+
+ {isPounce ? ( +
+ +
+ ) : ( +
+ {item.source.substring(0, 2).toUpperCase()} +
+ )} +
+
{item.domain}
+
{item.source}
+
+
+ +
+
+ {formatPrice(item.price)} +
+
+ {isPounce ? ( + + + Instant + + ) : ( + + + {displayTime || 'N/A'} + + )} +
+
+
+ + {/* Score & Actions */} +
+ = 80 ? "text-accent bg-accent/10" : + item.pounce_score >= 50 ? "text-amber-400 bg-amber-400/10" : + "text-white/40 bg-white/5" + )}> + Score {item.pounce_score} + + +
+ + + + + {isPounce ? 'Buy' : 'Bid'} + {!isPounce && } + +
+
+
) })}
- - + + + {isPounce ? 'Buy' : 'Bid'} + {!isPounce && } + +
+
+ + ) + })} + + + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Page {page} of {totalPages} +
+
+ + +
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + let pageNum: number + if (totalPages <= 5) { + pageNum = i + 1 + } else if (page <= 3) { + pageNum = i + 1 + } else if (page >= totalPages - 2) { + pageNum = totalPages - 4 + i + } else { + pageNum = page - 2 + i + } + + return ( + + ) + })} +
+ + + {page} / {totalPages} + + + +
+
+ )} + + )} +
+ + {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* MOBILE BOTTOM NAV */} + {/* ═══════════════════════════════════════════════════════════════════════ */} + + + {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* MOBILE DRAWER */} + {/* ═══════════════════════════════════════════════════════════════════════ */} + {menuOpen && ( +
+
setMenuOpen(false)} + /> + +
+ +
+
+ Pounce +
+

POUNCE

+

Terminal

+
+
+ +
+ +
+ {drawerNavSections.map((section) => ( +
+
+
+ {section.title} +
+
+ {section.items.map((item: any) => ( + setMenuOpen(false)} + className="flex items-center gap-4 px-6 py-3.5 text-white/70 active:text-white active:bg-white/[0.03] transition-colors border-l-2 border-transparent active:border-accent" + > + + {item.label} + {item.isNew && ( + + NEW + + )} + + + ))} +
+
+ ))} + +
+ setMenuOpen(false)} + className="flex items-center gap-3 py-3 text-white/60 active:text-white transition-colors" + > + + Settings + + + {user?.is_admin && ( + setMenuOpen(false)} + className="flex items-center gap-3 py-3 text-amber-500/80 active:text-amber-400 transition-colors" + > + + Admin Panel + + )} +
+
+ +
+
+
+ +
+
+

+ {user?.name || user?.email?.split('@')[0] || 'User'} +

+

+ {tierName} Plan +

+
+
+ + {tierName === 'Scout' && ( + setMenuOpen(false)} + className="flex items-center justify-center gap-2 w-full py-3 bg-white text-black text-sm font-bold rounded-lg active:scale-[0.98] transition-all mb-3 shadow-lg" + > + + Upgrade Now + + )} + + +
)} - - + + + {toast && } +
) -} \ No newline at end of file +} diff --git a/frontend/src/app/terminal/radar/page.tsx b/frontend/src/app/terminal/radar/page.tsx index 83dbd63..eafeb6b 100644 --- a/frontend/src/app/terminal/radar/page.tsx +++ b/frontend/src/app/terminal/radar/page.tsx @@ -457,6 +457,78 @@ export default function RadarPage() {
+ {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* WATCHLIST PREVIEW */} + {/* ═══════════════════════════════════════════════════════════════════════ */} + {domains && domains.length > 0 && ( +
+
+

+ + Your Watchlist +

+ + Manage + + +
+ +
+ {domains.slice(0, 6).map((domain) => ( + +
+ {domain.is_available ? ( + + ) : ( + + )} +
+
+
+ {domain.domain} +
+
+ {domain.is_available ? 'Available!' : 'Monitoring'} +
+
+ {domain.is_available && ( + + GET IT + + )} + + ))} +
+ + {domains.length > 6 && ( + + View all {domains.length} domains + + + )} +
+ )} + {/* ═══════════════════════════════════════════════════════════════════════ */} {/* HOT AUCTIONS - Feed Style */} {/* ═══════════════════════════════════════════════════════════════════════ */}