From 2cc754b04d192666adceb0447c2ced265e4cc617 Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Wed, 10 Dec 2025 22:21:35 +0100 Subject: [PATCH] feat: Sprint 3 - Terminal screens rebuild according to concept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RADAR: - Added Ticker component for live market movements - Implemented Universal Search (simultaneous Whois + Auctions check) - Quick Stats: 3 cards (Watching, Market, My Listings) - Recent Alerts with Activity Feed MARKET: - Unified table with Pounce Score (0-100, color-coded) - Hide Spam toggle (default: ON) - Pounce Direct Only toggle - Source badges (GoDaddy, Sedo, Pounce) - Status/Time column with Instant vs Countdown INTEL: - Added Cheapest At column (Best Registrar Finder) - Renamed to Intel - Inflation Monitor with renewal trap warnings WATCHLIST: - Tabs: Watching / My Portfolio - Health Status Ampel (🟢🟡🔴) - Improved status display LISTING: - Scout paywall (only Trader/Tycoon can list) - Tier limits: Trader=5, Tycoon=50 - DNS Verification workflow --- frontend/src/app/terminal/intel/page.tsx | 24 +- frontend/src/app/terminal/listing/page.tsx | 34 +- frontend/src/app/terminal/market/page.tsx | 761 ++++++++++--------- frontend/src/app/terminal/radar/page.tsx | 409 ++++++---- frontend/src/app/terminal/watchlist/page.tsx | 54 +- frontend/src/components/Ticker.tsx | 165 ++++ frontend/src/lib/api.ts | 17 + 7 files changed, 907 insertions(+), 557 deletions(-) create mode 100644 frontend/src/components/Ticker.tsx diff --git a/frontend/src/app/terminal/intel/page.tsx b/frontend/src/app/terminal/intel/page.tsx index 1eae401..6ea5ae8 100755 --- a/frontend/src/app/terminal/intel/page.tsx +++ b/frontend/src/app/terminal/intel/page.tsx @@ -271,6 +271,28 @@ export default function TLDPricingPage() { ) }, }, + { + key: 'cheapest', + header: 'Cheapest At', + align: 'left' as const, + width: '140px', + hideOnMobile: true, + render: (tld: TLDData) => ( + tld.cheapest_registrar ? ( + e.stopPropagation()} + className="text-xs text-accent hover:text-accent/80 hover:underline transition-colors" + > + {tld.cheapest_registrar} + + ) : ( + — + ) + ), + }, { key: 'risk', header: 'Risk', @@ -304,7 +326,7 @@ export default function TLDPricingPage() { return ( diff --git a/frontend/src/app/terminal/listing/page.tsx b/frontend/src/app/terminal/listing/page.tsx index deb53b6..5edc41a 100755 --- a/frontend/src/app/terminal/listing/page.tsx +++ b/frontend/src/app/terminal/listing/page.tsx @@ -227,9 +227,11 @@ export default function MyListingsPage() { return {status} } + // Tier limits as per concept: Scout = 0 (blocked), Trader = 5, Tycoon = 50 const tier = subscription?.tier || 'scout' - const limits = { scout: 2, trader: 10, tycoon: 50 } - const maxListings = limits[tier as keyof typeof limits] || 2 + const limits = { scout: 0, trader: 5, tycoon: 50 } + const maxListings = limits[tier as keyof typeof limits] || 0 + const canList = tier !== 'scout' return ( + {/* Scout Paywall */} + {!canList && ( +
+ +

Upgrade to List Domains

+

+ The Pounce marketplace is exclusive to Trader and Tycoon members. + List your domains, get verified, and sell directly to buyers with 0% commission. +

+
+ + Upgrade to Trader • $9/mo + +
+
+ )} + {/* Messages */} {error && (
@@ -275,7 +297,8 @@ export default function MyListingsPage() {
)} - {/* Stats */} + {/* Stats - only show if can list */} + {canList && (
+ )} {/* Listings */} - {loading ? ( + {canList && ( + loading ? (
@@ -405,6 +430,7 @@ export default function MyListingsPage() { ))} + ) )}
diff --git a/frontend/src/app/terminal/market/page.tsx b/frontend/src/app/terminal/market/page.tsx index 3dd1a91..d3aa696 100644 --- a/frontend/src/app/terminal/market/page.tsx +++ b/frontend/src/app/terminal/market/page.tsx @@ -1,17 +1,15 @@ '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 { TerminalLayout } from '@/components/TerminalLayout' import { PremiumTable, Badge, - PlatformBadge, StatCard, PageContainer, SearchInput, - TabBar, FilterBar, SelectDropdown, ActionButton, @@ -32,10 +30,15 @@ import { Crown, Plus, Check, + Diamond, + Store, + Filter, + ShoppingBag, } from 'lucide-react' import Link from 'next/link' import clsx from 'clsx' +// Types interface Auction { domain: string platform: string @@ -53,72 +56,99 @@ interface Auction { affiliate_url: string } -interface Opportunity { - auction: Auction - analysis: { - opportunity_score: number - urgency?: string - competition?: string - price_range?: string - recommendation: string - reasoning?: string - } +interface PounceDirectListing { + id: number + domain: string + price: number + is_negotiable: boolean + verified: boolean + seller_name: string + created_at: string } -type TabType = 'all' | 'ending' | 'hot' | 'opportunities' -type SortField = 'ending' | 'bid_asc' | 'bid_desc' | 'bids' | 'score' -type FilterPreset = 'all' | 'no-trash' | 'short' | 'high-value' | 'low-competition' +interface MarketItem { + type: 'auction' | 'direct' + domain: string + price: number + source: string + sourceIcon: 'godaddy' | 'sedo' | 'namejet' | 'dropcatch' | 'pounce' + status: 'auction' | 'instant' + timeRemaining?: string + numBids?: number + pounceScore: number + affiliateUrl?: string + isPounce: boolean + verified?: boolean + ageYears?: number +} -const PLATFORMS = [ - { value: 'All', label: 'All Sources' }, - { value: 'GoDaddy', label: 'GoDaddy' }, - { value: 'Sedo', label: 'Sedo' }, - { value: 'NameJet', label: 'NameJet' }, - { value: 'DropCatch', label: 'DropCatch' }, +type FilterType = 'all' | 'hide-spam' | 'pounce-only' + +const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'net', 'org', 'app', 'dev', 'ch'] + +const TLD_OPTIONS = [ + { value: 'all', label: 'All TLDs' }, + { value: 'com', label: '.com' }, + { value: 'ai', label: '.ai' }, + { value: 'io', label: '.io' }, + { value: 'ch', label: '.ch' }, + { value: 'net', label: '.net' }, + { value: 'org', label: '.org' }, ] -const FILTER_PRESETS: { id: FilterPreset, label: string, icon: typeof Gavel, description: string, proOnly?: boolean }[] = [ - { 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: 'short', label: 'Short', icon: Zap, description: '4-letter domains or less' }, - { id: 'high-value', label: 'High Value', icon: Crown, description: 'Premium TLDs with high activity', proOnly: true }, - { id: 'low-competition', label: 'Low Competition', icon: Target, description: 'Under 5 bids' }, +const PRICE_OPTIONS = [ + { value: 'all', label: 'Any Price' }, + { value: '100', label: '< $100' }, + { value: '500', label: '< $500' }, + { value: '1000', label: '< $1,000' }, + { value: '5000', label: '< $5,000' }, + { value: '10000', label: '< $10,000' }, ] -const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'net', 'org', 'app', 'dev'] +// Calculate Pounce Score (0-100) +function calculatePounceScore(domain: string, tld: string, numBids?: number, ageYears?: number): number { + let score = 50 + const name = domain.split('.')[0] + + // Domain length bonus + if (name.length <= 3) score += 30 + else if (name.length <= 4) score += 25 + else if (name.length <= 6) score += 15 + else if (name.length <= 8) score += 5 + + // TLD bonus + if (['com', 'io', 'ai'].includes(tld)) score += 15 + else if (['co', 'net', 'org', 'ch'].includes(tld)) score += 10 + else if (['app', 'dev'].includes(tld)) score += 5 + + // Age bonus + if (ageYears && ageYears > 15) score += 15 + else if (ageYears && ageYears > 10) score += 10 + else if (ageYears && ageYears > 5) score += 5 + + // Bidding activity (demand indicator) + if (numBids && numBids >= 30) score += 10 + else if (numBids && numBids >= 15) score += 5 + + // Spam penalty + if (name.includes('-')) score -= 20 + if (name.length > 4 && /\d/.test(name)) score -= 15 + if (name.length > 15) score -= 15 + + return Math.max(0, Math.min(100, score)) +} -// Pure functions (no hooks needed) -function isCleanDomain(auction: Auction): boolean { - const name = auction.domain.split('.')[0] +// Check if domain is "clean" (no spam indicators) +function isCleanDomain(domain: string, tld: string): boolean { + const name = domain.split('.')[0] if (name.includes('-')) return false if (name.length > 4 && /\d/.test(name)) return false if (name.length > 12) return false - if (!PREMIUM_TLDS.includes(auction.tld)) return false + if (!PREMIUM_TLDS.includes(tld)) return false return true } -function calculateDealScore(auction: Auction): number { - let score = 50 - const name = auction.domain.split('.')[0] - if (name.length <= 4) score += 25 - else if (name.length <= 6) score += 15 - else if (name.length <= 8) score += 5 - if (['com', 'io', 'ai'].includes(auction.tld)) score += 15 - else if (['co', 'net', 'org'].includes(auction.tld)) score += 5 - if (auction.age_years && auction.age_years > 10) score += 15 - else if (auction.age_years && auction.age_years > 5) score += 10 - if (auction.num_bids >= 20) score += 10 - else if (auction.num_bids >= 10) score += 5 - if (isCleanDomain(auction)) score += 10 - 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' -} - +// Format currency const formatCurrency = (value: number) => { return new Intl.NumberFormat('en-US', { style: 'currency', @@ -128,74 +158,85 @@ const formatCurrency = (value: number) => { }).format(value) } -export default function AuctionsPage() { +// Get Pounce Score color +function getScoreColor(score: number): string { + if (score >= 80) return 'text-accent bg-accent/20' + if (score >= 40) return 'text-amber-400 bg-amber-400/20' + return 'text-red-400 bg-red-400/20' +} + +// Source Badge Component +function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }) { + if (isPounce) { + return ( +
+ + Pounce +
+ ) + } + + const colors: Record = { + GoDaddy: 'text-orange-400 bg-orange-400/10 border-orange-400/20', + Sedo: 'text-blue-400 bg-blue-400/10 border-blue-400/20', + NameJet: 'text-purple-400 bg-purple-400/10 border-purple-400/20', + DropCatch: 'text-cyan-400 bg-cyan-400/10 border-cyan-400/20', + } + + return ( +
+ + {source} +
+ ) +} + +export default function MarketPage() { const { isAuthenticated, subscription } = useStore() - const [allAuctions, setAllAuctions] = useState([]) - const [endingSoon, setEndingSoon] = useState([]) - const [hotAuctions, setHotAuctions] = useState([]) - const [opportunities, setOpportunities] = useState([]) + const [auctions, setAuctions] = useState([]) + const [directListings, setDirectListings] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) - const [activeTab, setActiveTab] = useState('all') - const [sortBy, setSortBy] = useState('ending') - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') // Filters + const [hideSpam, setHideSpam] = useState(true) // Default: ON (as per concept) + const [pounceOnly, setPounceOnly] = useState(false) const [searchQuery, setSearchQuery] = useState('') - const [selectedPlatform, setSelectedPlatform] = useState('All') - const [maxBid, setMaxBid] = useState('') - const [filterPreset, setFilterPreset] = useState('all') + const [selectedTld, setSelectedTld] = useState('all') + const [selectedPrice, setSelectedPrice] = useState('all') const [trackedDomains, setTrackedDomains] = useState>(new Set()) const [trackingInProgress, setTrackingInProgress] = useState(null) const isPaidUser = subscription?.tier === 'trader' || subscription?.tier === 'tycoon' - // Data loading + // Load data const loadData = useCallback(async () => { setLoading(true) try { - const [auctionsData, hotData, endingData] = await Promise.all([ - api.getAuctions(), - api.getHotAuctions(50), - api.getEndingSoonAuctions(24, 50), + const [auctionsData, listingsData] = await Promise.all([ + api.getAuctions().catch(() => ({ auctions: [] })), + api.getMarketplaceListings().catch(() => ({ listings: [] })), ]) - setAllAuctions(auctionsData.auctions || []) - setHotAuctions(hotData || []) - setEndingSoon(endingData || []) + setAuctions(auctionsData.auctions || []) + setDirectListings(listingsData.listings || []) } catch (error) { - console.error('Failed to load auction data:', error) + console.error('Failed to load market data:', error) } finally { setLoading(false) } }, []) - 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) await loadData() - if (isAuthenticated) await loadOpportunities() setRefreshing(false) - }, [loadData, loadOpportunities, isAuthenticated]) + }, [loadData]) const handleTrackDomain = useCallback(async (domain: string) => { if (trackedDomains.has(domain)) return @@ -211,262 +252,249 @@ export default function AuctionsPage() { } }, [trackedDomains]) - const handleSort = useCallback((field: string) => { - const f = field as SortField - if (sortBy === f) { - setSortDirection(d => d === 'asc' ? 'desc' : 'asc') - } else { - setSortBy(f) - setSortDirection('asc') - } - }, [sortBy]) - - // Memoized tabs - const tabs = useMemo(() => [ - { id: 'all', label: 'All', icon: Gavel, count: allAuctions.length }, - { id: 'ending', label: 'Ending Soon', icon: Timer, count: endingSoon.length, color: 'warning' as const }, - { id: 'hot', label: 'Hot', icon: Flame, count: hotAuctions.length }, - { id: 'opportunities', label: 'Opportunities', icon: Target, count: opportunities.length }, - ], [allAuctions.length, endingSoon.length, hotAuctions.length, opportunities.length]) - - // Filter and sort 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] - } - - // Apply preset filter - const baseFilter = filterPreset === 'all' && isPaidUser ? 'no-trash' : filterPreset - switch (baseFilter) { - case 'no-trash': auctions = auctions.filter(isCleanDomain); break - case 'short': auctions = auctions.filter(a => a.domain.split('.')[0].length <= 4); break - case 'high-value': auctions = auctions.filter(a => - 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 - if (selectedPlatform !== 'All') { - auctions = auctions.filter(a => a.platform === selectedPlatform) - } - - // Apply max bid - if (maxBid) { - const max = parseFloat(maxBid) - 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) { - case 'ending': return mult * (new Date(a.end_time).getTime() - new Date(b.end_time).getTime()) - case 'bid_asc': - case 'bid_desc': return mult * (a.current_bid - b.current_bid) - case 'bids': return mult * (b.num_bids - a.num_bids) - case 'score': return mult * (calculateDealScore(b) - calculateDealScore(a)) - default: return 0 - } + // Combine and filter market items + const marketItems = useMemo(() => { + const items: MarketItem[] = [] + + // Add auctions (unless pounceOnly is active) + if (!pounceOnly) { + auctions.forEach(auction => { + const score = calculatePounceScore(auction.domain, auction.tld, auction.num_bids, auction.age_years || undefined) + + // Apply spam filter + if (hideSpam && !isCleanDomain(auction.domain, auction.tld)) return + + items.push({ + type: 'auction', + domain: auction.domain, + price: auction.current_bid, + source: auction.platform, + sourceIcon: auction.platform.toLowerCase() as any, + status: 'auction', + timeRemaining: auction.time_remaining, + numBids: auction.num_bids, + pounceScore: score, + affiliateUrl: auction.affiliate_url, + isPounce: false, + ageYears: auction.age_years || undefined, + }) }) } + + // Add Pounce Direct listings + directListings.forEach(listing => { + const tld = listing.domain.split('.').pop() || '' + const score = calculatePounceScore(listing.domain, tld) + + // Apply spam filter + if (hideSpam && !isCleanDomain(listing.domain, tld)) return + + items.push({ + type: 'direct', + domain: listing.domain, + price: listing.price, + source: 'Pounce', + sourceIcon: 'pounce', + status: 'instant', + pounceScore: score + 10, // Bonus for verified listings + isPounce: true, + verified: listing.verified, + }) + }) + + // Apply search filter + let filtered = items + if (searchQuery) { + const q = searchQuery.toLowerCase() + filtered = filtered.filter(item => item.domain.toLowerCase().includes(q)) + } + + // Apply TLD filter + if (selectedTld !== 'all') { + filtered = filtered.filter(item => item.domain.endsWith(`.${selectedTld}`)) + } + + // Apply price filter + if (selectedPrice !== 'all') { + const maxPrice = parseInt(selectedPrice) + filtered = filtered.filter(item => item.price <= maxPrice) + } + + // Sort: Pounce Direct first, then by score + filtered.sort((a, b) => { + if (a.isPounce && !b.isPounce) return -1 + if (!a.isPounce && b.isPounce) return 1 + return b.pounceScore - a.pounceScore + }) + + return filtered + }, [auctions, directListings, hideSpam, pounceOnly, searchQuery, selectedTld, selectedPrice]) - return auctions - }, [activeTab, allAuctions, endingSoon, hotAuctions, opportunities, filterPreset, isPaidUser, searchQuery, selectedPlatform, maxBid, sortBy, sortDirection]) + // Stats + const stats = useMemo(() => ({ + total: marketItems.length, + auctions: marketItems.filter(i => i.type === 'auction').length, + direct: marketItems.filter(i => i.type === 'direct').length, + highScore: marketItems.filter(i => i.pounceScore >= 80).length, + }), [marketItems]) - // Subtitle - const subtitle = useMemo(() => { - if (loading) return 'Loading live auctions...' - const total = allAuctions.length - if (total === 0) return 'No active auctions found' - return `${sortedAuctions.length.toLocaleString()} auctions ${sortedAuctions.length < total ? `(filtered from ${total.toLocaleString()})` : 'across 4 platforms'}` - }, [loading, allAuctions.length, sortedAuctions.length]) - - // Get opportunity data helper - const getOpportunityData = useCallback((domain: string) => { - if (activeTab !== 'opportunities') return null - return opportunities.find(o => o.auction.domain === domain)?.analysis - }, [activeTab, opportunities]) - - // Table columns - memoized + // Table columns const columns = useMemo(() => [ { key: 'domain', header: 'Domain', - sortable: true, - render: (a: Auction) => ( -
- - {a.domain} - + render: (item: MarketItem) => ( +
+
+ {item.isPounce && } + {item.domain} + {item.verified && ( + ✓ Verified + )} +
- - {a.age_years && {a.age_years}y} +
), }, - { - key: 'platform', - header: 'Platform', - hideOnMobile: true, - render: (a: Auction) => ( -
- - {a.age_years && ( - - {a.age_years}y - - )} -
- ), - }, - { - key: 'bid_asc', - header: 'Bid', - sortable: true, - align: 'right' as const, - render: (a: Auction) => ( -
- {formatCurrency(a.current_bid)} - {a.buy_now_price && ( -

Buy: {formatCurrency(a.buy_now_price)}

- )} -
- ), - }, { key: 'score', - header: 'Deal Score', - sortable: true, + header: 'Pounce Score', align: 'center' as const, - hideOnMobile: true, - render: (a: Auction) => { - if (activeTab === 'opportunities') { - const oppData = getOpportunityData(a.domain) - if (oppData) { - return ( - - {oppData.opportunity_score} - - ) - } - } - - if (!isPaidUser) { + render: (item: MarketItem) => { + if (!isPaidUser && !item.isPounce) { return ( ) } - const score = calculateDealScore(a) return ( -
- = 75 ? "bg-accent/20 text-accent" : - score >= 50 ? "bg-amber-500/20 text-amber-400" : - "bg-foreground/10 text-foreground-muted" - )}> - {score} - - {score >= 75 && Undervalued} + + {item.pounceScore} + + ) + }, + }, + { + key: 'price', + header: 'Price / Bid', + align: 'right' as const, + render: (item: MarketItem) => ( +
+ + {formatCurrency(item.price)} + + {item.type === 'auction' && ( + (Bid) + )} +
+ ), + }, + { + key: 'status', + header: 'Status / Time', + align: 'center' as const, + hideOnMobile: true, + render: (item: MarketItem) => { + if (item.type === 'direct') { + return ( +
+ + Instant +
+ ) + } + + const isUrgent = item.timeRemaining?.includes('m') && !item.timeRemaining?.includes('h') + const isWarning = item.timeRemaining?.includes('h') && parseInt(item.timeRemaining) < 2 + + return ( +
+ + {item.timeRemaining}
) }, }, { - key: 'bids', - header: 'Bids', - sortable: true, - align: 'right' as const, + key: 'source', + header: 'Source', + align: 'center' as const, hideOnMobile: true, - render: (a: Auction) => ( - = 20 ? "text-accent" : a.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted" - )}> - {a.num_bids} - {a.num_bids >= 20 && } - - ), - }, - { - key: 'ending', - header: 'Time Left', - sortable: true, - align: 'right' as const, - hideOnMobile: true, - render: (a: Auction) => ( - - {a.time_remaining} - - ), + render: (item: MarketItem) => , }, { key: 'actions', header: '', align: 'right' as const, - render: (a: Auction) => ( + render: (item: MarketItem) => (
- Bid + {item.isPounce ? 'Buy' : 'Bid'} + {!item.isPounce && }
), }, - ], [activeTab, isPaidUser, trackedDomains, trackingInProgress, handleTrackDomain, getOpportunityData]) + ], [isPaidUser, trackedDomains, trackingInProgress, handleTrackDomain]) + + const subtitle = loading + ? 'Loading market data...' + : `${stats.total} listings • ${stats.direct} direct • ${stats.auctions} auctions` return ( @@ -477,66 +505,60 @@ export default function AuctionsPage() { {/* Stats */}
- - - - + + 0} + /> + +
- {/* Tabs */} - setActiveTab(id as TabType)} /> - - {/* Smart Filter Presets */} -
- {FILTER_PRESETS.map((preset) => { - const isDisabled = preset.proOnly && !isPaidUser - const isActive = filterPreset === preset.id - const Icon = preset.icon - return ( - - ) - })} + {/* Filter Toggles (as per concept) */} +
+ + Filters: + + + {/* Hide Spam Toggle - Default ON */} + + + {/* Pounce Direct Only Toggle */} +
- {/* Tier notification for Scout users */} - {!isPaidUser && ( -
-
- -
-
-

You're seeing the raw auction feed

-

- Upgrade to Trader for spam-free listings, Deal Scores, and Smart Filters. -

-
- - Upgrade - -
- )} - - {/* Filters */} + {/* Search & Dropdowns */} - -
- - setMaxBid(e.target.value)} - 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 - focus:outline-none focus:border-accent/50 transition-all" - /> -
+ +
- {/* Table */} + {/* Upgrade Notice for Scout */} + {!isPaidUser && ( +
+
+ +
+
+

Upgrade for Full Market Intelligence

+

+ See Pounce Scores for all domains, unlock advanced filters, and get notified on deals. +

+
+ + Upgrade + +
+ )} + + {/* Market Table */} `${a.domain}-${a.platform}`} + data={marketItems} + keyExtractor={(item) => `${item.domain}-${item.source}`} loading={loading} - sortBy={sortBy} - sortDirection={sortDirection} - onSort={handleSort} - emptyIcon={} - emptyTitle={searchQuery ? `No auctions matching "${searchQuery}"` : "No auctions found"} + emptyIcon={} + emptyTitle={searchQuery ? `No listings matching "${searchQuery}"` : "No listings found"} emptyDescription="Try adjusting your filters or check back later" columns={columns} /> diff --git a/frontend/src/app/terminal/radar/page.tsx b/frontend/src/app/terminal/radar/page.tsx index 3e87ce5..be78530 100644 --- a/frontend/src/app/terminal/radar/page.tsx +++ b/frontend/src/app/terminal/radar/page.tsx @@ -5,24 +5,28 @@ import { useSearchParams } from 'next/navigation' import { useStore } from '@/lib/store' import { api } from '@/lib/api' import { TerminalLayout } from '@/components/TerminalLayout' -import { PremiumTable, StatCard, PageContainer, Badge, SectionHeader, SearchInput, ActionButton } from '@/components/PremiumTable' +import { Ticker, useTickerItems } from '@/components/Ticker' +import { PremiumTable, StatCard, PageContainer, Badge, SectionHeader, ActionButton } from '@/components/PremiumTable' import { Toast, useToast } from '@/components/Toast' import { Eye, - Briefcase, - TrendingUp, Gavel, + Tag, Clock, ExternalLink, Sparkles, - ChevronRight, Plus, Zap, Crown, Activity, - Loader2, - Search, Bell, + Search, + TrendingUp, + ArrowRight, + Globe, + CheckCircle2, + XCircle, + Loader2, } from 'lucide-react' import clsx from 'clsx' import Link from 'next/link' @@ -42,23 +46,34 @@ interface TrendingTld { reason: string } -export default function DashboardPage() { +interface SearchResult { + available: boolean | null + inAuction: boolean + inMarketplace: boolean + auctionData?: HotAuction + loading: boolean +} + +export default function RadarPage() { const searchParams = useSearchParams() const { isAuthenticated, isLoading, user, domains, - subscription + subscription, + addDomain, } = useStore() const { toast, showToast, hideToast } = useToast() const [hotAuctions, setHotAuctions] = useState([]) const [trendingTlds, setTrendingTlds] = useState([]) - const [loadingAuctions, setLoadingAuctions] = useState(true) - const [loadingTlds, setLoadingTlds] = useState(true) - const [quickDomain, setQuickDomain] = useState('') - const [addingDomain, setAddingDomain] = useState(false) + const [loadingData, setLoadingData] = useState(true) + + // Universal Search State + const [searchQuery, setSearchQuery] = useState('') + const [searchResult, setSearchResult] = useState(null) + const [addingToWatchlist, setAddingToWatchlist] = useState(false) // Check for upgrade success useEffect(() => { @@ -66,7 +81,7 @@ export default function DashboardPage() { showToast('Welcome to your upgraded plan! 🎉', 'success') window.history.replaceState({}, '', '/terminal/radar') } - }, [searchParams]) + }, [searchParams, showToast]) const loadDashboardData = useCallback(async () => { try { @@ -75,41 +90,87 @@ export default function DashboardPage() { api.getTrendingTlds().catch(() => ({ trending: [] })) ]) setHotAuctions(auctions.slice(0, 5)) - setTrendingTlds(trending.trending?.slice(0, 4) || []) + setTrendingTlds(trending.trending?.slice(0, 6) || []) } catch (error) { console.error('Failed to load dashboard data:', error) } finally { - setLoadingAuctions(false) - setLoadingTlds(false) + setLoadingData(false) } }, []) - // Load dashboard data useEffect(() => { if (isAuthenticated) { loadDashboardData() } }, [isAuthenticated, loadDashboardData]) - const handleQuickAdd = useCallback(async (e: React.FormEvent) => { - e.preventDefault() - if (!quickDomain.trim()) return - - setAddingDomain(true) + // Universal Search - simultaneous check + const handleSearch = useCallback(async (domain: string) => { + if (!domain.trim()) { + setSearchResult(null) + return + } + + const cleanDomain = domain.trim().toLowerCase() + setSearchResult({ available: null, inAuction: false, inMarketplace: false, loading: true }) + try { - const store = useStore.getState() - await store.addDomain(quickDomain.trim()) - setQuickDomain('') - showToast(`Added ${quickDomain.trim()} to watchlist`, 'success') + // Parallel checks + const [whoisResult, auctionsResult] = await Promise.all([ + api.checkDomain(cleanDomain, true).catch(() => null), + api.getAuctions(cleanDomain).catch(() => ({ auctions: [] })), + ]) + + const auctionMatch = (auctionsResult as any).auctions?.find( + (a: any) => a.domain.toLowerCase() === cleanDomain + ) + + const isAvailable = whoisResult && 'is_available' in whoisResult + ? whoisResult.is_available + : null + + setSearchResult({ + available: isAvailable, + inAuction: !!auctionMatch, + inMarketplace: false, // TODO: Check marketplace + auctionData: auctionMatch, + loading: false, + }) + } catch (error) { + setSearchResult({ available: null, inAuction: false, inMarketplace: false, loading: false }) + } + }, []) + + const handleAddToWatchlist = useCallback(async () => { + if (!searchQuery.trim()) return + + setAddingToWatchlist(true) + try { + await addDomain(searchQuery.trim()) + showToast(`Added ${searchQuery.trim()} to watchlist`, 'success') + setSearchQuery('') + setSearchResult(null) } catch (err: any) { showToast(err.message || 'Failed to add domain', 'error') } finally { - setAddingDomain(false) + setAddingToWatchlist(false) } - }, [quickDomain, showToast]) + }, [searchQuery, addDomain, showToast]) + + // Debounced search + useEffect(() => { + const timer = setTimeout(() => { + if (searchQuery.length > 3) { + handleSearch(searchQuery) + } else { + setSearchResult(null) + } + }, 500) + return () => clearTimeout(timer) + }, [searchQuery, handleSearch]) // Memoized computed values - const { availableDomains, totalDomains, tierName, TierIcon, greeting, subtitle } = useMemo(() => { + const { availableDomains, totalDomains, tierName, TierIcon, greeting, subtitle, listingsCount } = useMemo(() => { const availableDomains = domains?.filter(d => d.is_available) || [] const totalDomains = domains?.length || 0 const tierName = subscription?.tier_name || subscription?.tier || 'Scout' @@ -127,9 +188,15 @@ export default function DashboardPage() { subtitle = 'Start tracking domains to find opportunities' } - return { availableDomains, totalDomains, tierName, TierIcon, greeting, subtitle } + // TODO: Get actual listings count from API + const listingsCount = 0 + + return { availableDomains, totalDomains, tierName, TierIcon, greeting, subtitle, listingsCount } }, [domains, subscription]) + // Generate ticker items + const tickerItems = useTickerItems(trendingTlds, availableDomains, hotAuctions) + if (isLoading || !isAuthenticated) { return (
@@ -145,88 +212,166 @@ export default function DashboardPage() { > {toast && } - - {/* Quick Add */} -
-
-
-

-
- -
- Quick Add to Watchlist -

-
-
- - setQuickDomain(e.target.value)} - placeholder="Enter domain to track (e.g., dream.com)" - className="w-full h-11 pl-11 pr-4 bg-background/80 backdrop-blur-sm border border-border/50 rounded-xl - text-sm text-foreground placeholder:text-foreground-subtle - focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30" - /> -
- -
-
+ {/* A. THE TICKER - Market movements */} + {tickerItems.length > 0 && ( +
+
+ )} - {/* Stats Overview */} -
+ + {/* B. QUICK STATS - 3 Cards as per concept */} +
0 ? `${availableDomains.length} alerts` : undefined} icon={Eye} - /> - - - 0} /> - + 0 ? `${hotAuctions.length}+` : '0'} + subtitle="opportunities" + icon={Gavel} + /> + + + -
- {/* Activity Feed + Market Pulse */} + {/* C. UNIVERSAL SEARCH - Hero Element */} +
+
+ +
+
+
+ +
+
+

Universal Search

+

Check availability, auctions & marketplace simultaneously

+
+
+ +
+ + setSearchQuery(e.target.value)} + placeholder="Enter domain to check (e.g., dream.com)" + className="w-full h-14 pl-12 pr-4 bg-background/80 backdrop-blur-sm border border-border/50 rounded-xl + text-base text-foreground placeholder:text-foreground-subtle + focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20" + /> +
+ + {/* Search Results */} + {searchResult && ( +
+ {searchResult.loading ? ( +
+ + Checking... +
+ ) : ( +
+ {/* Availability */} +
+
+ {searchResult.available === true ? ( + + ) : searchResult.available === false ? ( + + ) : ( + + )} + + {searchResult.available === true + ? 'Available for registration!' + : searchResult.available === false + ? 'Currently registered' + : 'Could not check availability'} + +
+ {searchResult.available === true && ( + + Register Now + + )} +
+ + {/* In Auction */} + {searchResult.inAuction && searchResult.auctionData && ( +
+
+ + + In auction: ${searchResult.auctionData.current_bid} ({searchResult.auctionData.time_remaining}) + +
+ + Bid Now + +
+ )} + + {/* Action Buttons */} +
+ +
+
+ )} +
+ )} +
+
+ + {/* D. RECENT ALERTS + MARKET PULSE */}
- {/* Activity Feed */} + {/* Recent Alerts / Activity Feed */}
- View all → + + View all } /> @@ -234,7 +379,7 @@ export default function DashboardPage() {
{availableDomains.length > 0 ? (
- {availableDomains.slice(0, 4).map((domain) => ( + {availableDomains.slice(0, 5).map((domain) => (
-

{domain.name}

+

{domain.name}

Available for registration!

))} - {availableDomains.length > 4 && ( -

- +{availableDomains.length - 4} more available -

- )}
) : totalDomains > 0 ? (

All domains are still registered

- We're monitoring {totalDomains} domains for you + Monitoring {totalDomains} domains for you

) : ( @@ -276,7 +416,7 @@ export default function DashboardPage() {

No domains tracked yet

- Add a domain above to start monitoring + Use Universal Search above to start

)} @@ -291,14 +431,14 @@ export default function DashboardPage() { icon={Gavel} compact action={ - - View all → + + View all } />
- - {/* Trending TLDs */} -
-
- - View all → - - } - /> -
-
- {loadingTlds ? ( -
- {[...Array(4)].map((_, i) => ( -
- ))} -
- ) : trendingTlds.length > 0 ? ( -
- {trendingTlds.map((tld) => ( - -
-
-
- .{tld.tld} - 0 - ? "text-orange-400 bg-orange-400/10 border-orange-400/20" - : "text-accent bg-accent/10 border-accent/20" - )}> - {(tld.price_change || 0) > 0 ? '+' : ''}{(tld.price_change || 0).toFixed(1)}% - -
-

{tld.reason}

-
- - ))} -
- ) : ( -
- -

No trending TLDs available

-
- )} -
-
) diff --git a/frontend/src/app/terminal/watchlist/page.tsx b/frontend/src/app/terminal/watchlist/page.tsx index ba4fba8..9283ccf 100755 --- a/frontend/src/app/terminal/watchlist/page.tsx +++ b/frontend/src/app/terminal/watchlist/page.tsx @@ -37,52 +37,59 @@ import { import clsx from 'clsx' import Link from 'next/link' -// Health status badge colors and icons +// Health status badge colors and icons (Ampel-System as per concept) +// 🟢 Online, 🟡 DNS Changed, 🔴 Offline/Error const healthStatusConfig: Record = { healthy: { - label: 'Healthy', + label: 'Online', color: 'text-accent', bgColor: 'bg-accent/10 border-accent/20', icon: Activity, - description: 'Domain is active and well-maintained' + description: 'Domain is active and well-maintained', + ampel: '🟢' }, weakening: { - label: 'Weakening', + label: 'DNS Changed', color: 'text-amber-400', bgColor: 'bg-amber-400/10 border-amber-400/20', icon: AlertTriangle, - description: 'Warning signs detected - owner may be losing interest' + description: 'Warning signs detected - DNS or config changed', + ampel: '🟡' }, parked: { label: 'For Sale', color: 'text-orange-400', bgColor: 'bg-orange-400/10 border-orange-400/20', icon: ShoppingCart, - description: 'Domain is parked and likely for sale' + description: 'Domain is parked and likely for sale', + ampel: '🟡' }, critical: { - label: 'Critical', + label: 'Offline', color: 'text-red-400', bgColor: 'bg-red-400/10 border-red-400/20', icon: AlertTriangle, - description: 'Domain drop is imminent!' + description: 'Domain is offline or has critical errors', + ampel: '🔴' }, unknown: { label: 'Unknown', color: 'text-foreground-muted', bgColor: 'bg-foreground/5 border-border/30', icon: HelpCircle, - description: 'Could not determine status' + description: 'Could not determine status', + ampel: '⚪' }, } -type FilterStatus = 'all' | 'available' | 'watching' +type FilterStatus = 'watching' | 'portfolio' | 'available' export default function WatchlistPage() { const { domains, addDomain, deleteDomain, refreshDomain, subscription } = useStore() @@ -93,7 +100,7 @@ export default function WatchlistPage() { const [refreshingId, setRefreshingId] = useState(null) const [deletingId, setDeletingId] = useState(null) const [togglingNotifyId, setTogglingNotifyId] = useState(null) - const [filterStatus, setFilterStatus] = useState('all') + const [filterStatus, setFilterStatus] = useState('watching') const [searchQuery, setSearchQuery] = useState('') // Health check state @@ -120,16 +127,17 @@ export default function WatchlistPage() { return false } if (filterStatus === 'available' && !domain.is_available) return false - if (filterStatus === 'watching' && domain.is_available) return false + if (filterStatus === 'portfolio') return false // TODO: filter for verified own domains + // 'watching' shows all domains return true }) }, [domains, searchQuery, filterStatus]) - // Memoized tabs config + // Memoized tabs config - as per concept: Watching + My Portfolio 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 }, + { id: 'watching', label: 'Watching', icon: Eye, count: stats.domainsUsed }, + { id: 'portfolio', label: 'My Portfolio', icon: Shield, count: 0 }, // TODO: verified own domains + { id: 'available', label: 'Available', icon: Sparkles, count: stats.availableCount, color: 'accent' as const }, ], [stats]) // Callbacks - prevent recreation on every render @@ -234,18 +242,18 @@ export default function WatchlistPage() { ), }, { - key: 'status', - header: 'Status', - align: 'left' as const, + key: 'health', + header: 'Health', + align: 'center' as const, + width: '100px', hideOnMobile: true, render: (domain: any) => { const health = healthReports[domain.id] if (health) { const config = healthStatusConfig[health.status] - const Icon = config.icon return ( -
- +
+ {config.ampel} {config.label}
) @@ -255,7 +263,7 @@ export default function WatchlistPage() { "text-sm", domain.is_available ? "text-accent font-medium" : "text-foreground-muted" )}> - {domain.is_available ? 'Ready to pounce!' : 'Monitoring...'} + {domain.is_available ? '🟢 Available' : '⚪ Checking...'} ) }, diff --git a/frontend/src/components/Ticker.tsx b/frontend/src/components/Ticker.tsx new file mode 100644 index 0000000..e74e47f --- /dev/null +++ b/frontend/src/components/Ticker.tsx @@ -0,0 +1,165 @@ +'use client' + +import { useEffect, useState, useRef } from 'react' +import { TrendingUp, TrendingDown, AlertCircle, Sparkles, Gavel } from 'lucide-react' +import clsx from 'clsx' + +export interface TickerItem { + id: string + type: 'tld_change' | 'domain_available' | 'auction_ending' | 'alert' + message: string + value?: string + change?: number + urgent?: boolean +} + +interface TickerProps { + items: TickerItem[] + speed?: number // pixels per second +} + +export function Ticker({ items, speed = 50 }: TickerProps) { + const containerRef = useRef(null) + const contentRef = useRef(null) + const [animationDuration, setAnimationDuration] = useState(0) + + useEffect(() => { + if (contentRef.current && containerRef.current) { + const contentWidth = contentRef.current.scrollWidth + const duration = contentWidth / speed + setAnimationDuration(duration) + } + }, [items, speed]) + + if (items.length === 0) return null + + const getIcon = (type: TickerItem['type'], change?: number) => { + switch (type) { + case 'tld_change': + return change && change > 0 + ? + : + case 'domain_available': + return + case 'auction_ending': + return + case 'alert': + return + default: + return null + } + } + + const getValueColor = (type: TickerItem['type'], change?: number) => { + if (type === 'tld_change') { + return change && change > 0 ? 'text-orange-400' : 'text-accent' + } + return 'text-accent' + } + + // Duplicate items for seamless loop + const tickerItems = [...items, ...items] + + return ( +
+ {/* Fade edges */} +
+
+ +
+ {tickerItems.map((item, idx) => ( +
+ {getIcon(item.type, item.change)} + {item.message} + {item.value && ( + + {item.value} + + )} + {item.change !== undefined && ( + 0 ? "text-orange-400" : "text-accent" + )}> + {item.change > 0 ? '+' : ''}{item.change.toFixed(1)}% + + )} +
+ ))} +
+ + +
+ ) +} + +// Hook to generate ticker items from various data sources +export function useTickerItems( + trendingTlds: Array<{ tld: string; price_change: number; current_price: number }>, + availableDomains: Array<{ name: string }>, + hotAuctions: Array<{ domain: string; time_remaining: string }> +): TickerItem[] { + const items: TickerItem[] = [] + + // Add TLD changes + trendingTlds.forEach((tld) => { + items.push({ + id: `tld-${tld.tld}`, + type: 'tld_change', + message: `.${tld.tld}`, + value: `$${tld.current_price.toFixed(2)}`, + change: tld.price_change, + }) + }) + + // Add available domains + availableDomains.slice(0, 3).forEach((domain) => { + items.push({ + id: `available-${domain.name}`, + type: 'domain_available', + message: `${domain.name} is available!`, + urgent: true, + }) + }) + + // Add ending auctions + hotAuctions.slice(0, 3).forEach((auction) => { + items.push({ + id: `auction-${auction.domain}`, + type: 'auction_ending', + message: `${auction.domain}`, + value: auction.time_remaining, + }) + }) + + return items +} + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 414247f..246217f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -323,6 +323,23 @@ class ApiClient { }) } + // Marketplace Listings (Pounce Direct) + async getMarketplaceListings() { + // TODO: Implement backend endpoint for marketplace listings + // For now, return empty array + return Promise.resolve({ + listings: [] as Array<{ + id: number + domain: string + price: number + is_negotiable: boolean + verified: boolean + seller_name: string + created_at: string + }> + }) + } + // Subscription async getSubscription() { return this.request<{