diff --git a/frontend/src/app/terminal/market/page.tsx b/frontend/src/app/terminal/market/page.tsx index d3aa696..1ead235 100644 --- a/frontend/src/app/terminal/market/page.tsx +++ b/frontend/src/app/terminal/market/page.tsx @@ -4,223 +4,298 @@ 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, - StatCard, - PageContainer, - SearchInput, - FilterBar, - SelectDropdown, - ActionButton, -} from '@/components/PremiumTable' import { - Clock, ExternalLink, - Flame, - Timer, - Gavel, - DollarSign, - RefreshCw, - Target, Loader2, - Sparkles, - Eye, + Diamond, + Timer, Zap, - Crown, + Filter, + ChevronDown, Plus, Check, - Diamond, - Store, - Filter, - ShoppingBag, + TrendingUp, + RefreshCw, } from 'lucide-react' import Link from 'next/link' import clsx from 'clsx' -// Types +// ============================================================================ +// TYPES +// ============================================================================ + interface Auction { domain: string platform: string - platform_url: string + platform_url?: string current_bid: number - currency: string + currency?: string num_bids: number - end_time: string time_remaining: string - buy_now_price: number | null - reserve_met: boolean | null - traffic: number | null - age_years: number | null + end_time: string + buy_now_price?: number | null + reserve_met?: boolean | null + traffic?: number | null tld: string affiliate_url: string -} - -interface PounceDirectListing { - id: number - domain: string - price: number - is_negotiable: boolean - verified: boolean - seller_name: string - created_at: string + age_years?: number | null } interface MarketItem { - type: 'auction' | 'direct' + id: string domain: string - price: number - source: string - sourceIcon: 'godaddy' | 'sedo' | 'namejet' | 'dropcatch' | 'pounce' - status: 'auction' | 'instant' - timeRemaining?: string - numBids?: number pounceScore: number - affiliateUrl?: string + price: number + priceType: 'bid' | 'fixed' + status: 'auction' | 'instant' + timeLeft?: string + source: 'GoDaddy' | 'Sedo' | 'NameJet' | 'DropCatch' | 'Pounce' isPounce: boolean verified?: boolean - ageYears?: number + affiliateUrl?: string + tld: string + numBids?: number } -type FilterType = 'all' | 'hide-spam' | 'pounce-only' +// ============================================================================ +// POUNCE SCORE ALGORITHM +// ============================================================================ -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 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' }, -] - -// 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 + // Length bonus (shorter = better) 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 + else if (name.length === 4) score += 25 + else if (name.length === 5) score += 20 + else if (name.length <= 7) score += 10 + else if (name.length <= 10) score += 5 + else 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 + // Premium TLD bonus + if (['com', 'ai', 'io'].includes(tld)) score += 15 + else if (['co', 'net', 'org', 'ch', 'de'].includes(tld)) score += 10 + else if (['app', 'dev', 'xyz'].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 + if (ageYears && ageYears > 15) score += 10 + else if (ageYears && ageYears > 10) score += 7 + else if (ageYears && ageYears > 5) score += 3 - // Bidding activity (demand indicator) - if (numBids && numBids >= 30) score += 10 - else if (numBids && numBids >= 15) score += 5 + // 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 - // Spam penalty - if (name.includes('-')) score -= 20 - if (name.length > 4 && /\d/.test(name)) score -= 15 + // SPAM PENALTIES + 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 return Math.max(0, Math.min(100, score)) } -// Check if domain is "clean" (no spam indicators) -function isCleanDomain(domain: string, tld: string): boolean { +function isSpamDomain(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(tld)) return false - return true + if (name.includes('-')) return true + if (/\d/.test(name) && name.length > 4) return true + if (name.length > 20) return true + if (!['com', 'ai', 'io', 'co', 'net', 'org', 'ch', 'de', 'app', 'dev', 'xyz', 'tech', 'cloud'].includes(tld)) return true + return false } -// Format currency -const formatCurrency = (value: number) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value) +// ============================================================================ +// COMPONENTS +// ============================================================================ + +// Score Badge with color coding +function ScoreBadge({ score, showLabel = false }: { score: number; showLabel?: boolean }) { + const color = score >= 80 + ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' + : score >= 40 + ? 'bg-amber-500/20 text-amber-400 border-amber-500/30' + : 'bg-red-500/20 text-red-400 border-red-500/30' + + return ( +
+ {score} + {showLabel && pts} +
+ ) } -// 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 +// Source Badge function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }) { if (isPounce) { return ( -
- - Pounce +
+ + 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', + 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} + + {source} + + ) +} + +// Status Badge +function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; timeLeft?: string }) { + if (status === 'instant') { + return ( +
+ + Instant +
+ ) + } + + // Check urgency + const isUrgent = timeLeft?.includes('m') && !timeLeft?.includes('d') && !timeLeft?.includes('h') + const isWarning = timeLeft?.includes('h') && parseInt(timeLeft) <= 4 + + return ( +
+ + + {timeLeft} +
) } +// Toggle Button +function ToggleButton({ + active, + onClick, + children +}: { + active: boolean + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +// Dropdown Select +function DropdownSelect({ + value, + onChange, + options, + label +}: { + value: string + onChange: (v: string) => void + options: { value: string; label: string }[] + label: string +}) { + return ( +
+ + +
+ ) +} + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + export default function MarketPage() { const { isAuthenticated, subscription } = useStore() + // Data State const [auctions, setAuctions] = useState([]) - const [directListings, setDirectListings] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) - // Filters - const [hideSpam, setHideSpam] = useState(true) // Default: ON (as per concept) + // Filter State + const [hideSpam, setHideSpam] = useState(true) // Default: ON const [pounceOnly, setPounceOnly] = useState(false) - const [searchQuery, setSearchQuery] = useState('') const [selectedTld, setSelectedTld] = useState('all') const [selectedPrice, setSelectedPrice] = useState('all') + const [searchQuery, setSearchQuery] = useState('') + + // Watchlist State const [trackedDomains, setTrackedDomains] = useState>(new Set()) const [trackingInProgress, setTrackingInProgress] = useState(null) - - const isPaidUser = subscription?.tier === 'trader' || subscription?.tier === 'tycoon' - // Load data + // Options + 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' }, + ] + + const PRICE_OPTIONS = [ + { value: 'all', label: 'Any Price' }, + { value: '100', label: '< $100' }, + { value: '1000', label: '< $1,000' }, + { value: '10000', label: 'High Roller' }, + ] + + // Load Data const loadData = useCallback(async () => { setLoading(true) try { - const [auctionsData, listingsData] = await Promise.all([ - api.getAuctions().catch(() => ({ auctions: [] })), - api.getMarketplaceListings().catch(() => ({ listings: [] })), - ]) - - setAuctions(auctionsData.auctions || []) - setDirectListings(listingsData.listings || []) + const data = await api.getAuctions() + setAuctions(data.auctions || []) } catch (error) { console.error('Failed to load market data:', error) } finally { @@ -238,370 +313,314 @@ export default function MarketPage() { setRefreshing(false) }, [loadData]) - const handleTrackDomain = useCallback(async (domain: string) => { - if (trackedDomains.has(domain)) return + 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 domain:', error) + console.error('Failed to track:', error) } finally { setTrackingInProgress(null) } - }, [trackedDomains]) + }, [trackedDomains, trackingInProgress]) - // Combine and filter market items + // Transform and Filter Data 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 + // 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: auction.time_remaining, + source: auction.platform as any, + isPounce: false, + affiliateUrl: auction.affiliate_url, + tld: auction.tld, + numBids: auction.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 + 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) + } + } + + // 5. Search 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 - }) - + + // Sort by Pounce Score (highest first) + filtered.sort((a, b) => b.pounceScore - a.pounceScore) + return filtered - }, [auctions, directListings, hideSpam, pounceOnly, searchQuery, selectedTld, selectedPrice]) + }, [auctions, hideSpam, pounceOnly, selectedTld, selectedPrice, searchQuery]) // 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, + avgScore: marketItems.length > 0 + ? Math.round(marketItems.reduce((sum, i) => sum + i.pounceScore, 0) / marketItems.length) + : 0, }), [marketItems]) - // Table columns - const columns = useMemo(() => [ - { - key: 'domain', - header: 'Domain', - render: (item: MarketItem) => ( -
-
- {item.isPounce && } - {item.domain} - {item.verified && ( - ✓ Verified - )} -
-
- -
-
- ), - }, - { - key: 'score', - header: 'Pounce Score', - align: 'center' as const, - render: (item: MarketItem) => { - if (!isPaidUser && !item.isPounce) { - return ( - - - - ) - } - - return ( - - {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: 'source', - header: 'Source', - align: 'center' as const, - hideOnMobile: true, - render: (item: MarketItem) => , - }, - { - key: 'actions', - header: '', - align: 'right' as const, - render: (item: MarketItem) => ( -
- - - {item.isPounce ? 'Buy' : 'Bid'} - {!item.isPounce && } - -
- ), - }, - ], [isPaidUser, trackedDomains, trackingInProgress, handleTrackDomain]) - - const subtitle = loading - ? 'Loading market data...' - : `${stats.total} listings • ${stats.direct} direct • ${stats.auctions} auctions` + // 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 ( - {refreshing ? '' : 'Refresh'} - - } + subtitle={loading ? 'Loading opportunities...' : `${stats.total} domains • ${stats.highScore} with score ≥80`} > - - {/* Stats */} -
- - 0} - /> - - -
- - {/* Filter Toggles (as per concept) */} -
- - Filters: - - - {/* Hide Spam Toggle - Default ON */} - - - {/* Pounce Direct Only Toggle */} - -
- - {/* Search & Dropdowns */} - - - - - - - {/* Upgrade Notice for Scout */} - {!isPaidUser && ( -
-
- +
+ + {/* ================================================================ */} + {/* FILTER BAR */} + {/* ================================================================ */} +
+
+ {/* Filter Icon */} +
+ + Filters
-
-

Upgrade for Full Market Intelligence

-

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

+ + {/* 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" + />
- - Upgrade - + +
- )} +
- {/* Market Table */} - `${item.domain}-${item.source}`} - loading={loading} - emptyIcon={} - emptyTitle={searchQuery ? `No listings matching "${searchQuery}"` : "No listings found"} - emptyDescription="Try adjusting your filters or check back later" - columns={columns} - /> - + {/* ================================================================ */} + {/* MARKET TABLE */} + {/* ================================================================ */} +
+ + {/* Table Header */} +
+
Domain
+
Score
+
Price / Bid
+
Status
+
Source
+
Action
+
+ + {/* Table Body */} + {loading ? ( +
+ +
+ ) : marketItems.length === 0 ? ( +
+ +

No domains match your filters

+

Try adjusting your filter settings

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

{item.numBids} bids

+ )} +
+ + {/* Status / Time */} +
+ +
+ + {/* Source */} +
+ +
+ + {/* Actions */} +
+ {/* Track Button */} + + + {/* Action Button */} + + {item.isPounce ? 'Buy' : 'Bid'} + + +
+
+ ))} +
+ )} +
+ + {/* ================================================================ */} + {/* FOOTER INFO */} + {/* ================================================================ */} +
+ + Showing {marketItems.length} of {auctions.length} total listings + + + Data from GoDaddy, Sedo, NameJet, DropCatch • Updated every 15 minutes + +
+ +
) }