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
+
+
+
+
)
}