feat: Sprint 3 - Terminal screens rebuild according to concept
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
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
This commit is contained in:
@ -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 ? (
|
||||||
|
<a
|
||||||
|
href={tld.cheapest_registrar_url || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-xs text-accent hover:text-accent/80 hover:underline transition-colors"
|
||||||
|
>
|
||||||
|
{tld.cheapest_registrar}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-foreground-subtle">—</span>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'risk',
|
key: 'risk',
|
||||||
header: 'Risk',
|
header: 'Risk',
|
||||||
@ -304,7 +326,7 @@ export default function TLDPricingPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TerminalLayout
|
<TerminalLayout
|
||||||
title="TLD Pricing"
|
title="Intel"
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
actions={
|
actions={
|
||||||
<ActionButton onClick={handleRefresh} disabled={refreshing} variant="ghost" icon={refreshing ? Loader2 : RefreshCw}>
|
<ActionButton onClick={handleRefresh} disabled={refreshing} variant="ghost" icon={refreshing ? Loader2 : RefreshCw}>
|
||||||
|
|||||||
@ -227,9 +227,11 @@ export default function MyListingsPage() {
|
|||||||
return <Badge>{status}</Badge>
|
return <Badge>{status}</Badge>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tier limits as per concept: Scout = 0 (blocked), Trader = 5, Tycoon = 50
|
||||||
const tier = subscription?.tier || 'scout'
|
const tier = subscription?.tier || 'scout'
|
||||||
const limits = { scout: 2, trader: 10, tycoon: 50 }
|
const limits = { scout: 0, trader: 5, tycoon: 50 }
|
||||||
const maxListings = limits[tier as keyof typeof limits] || 2
|
const maxListings = limits[tier as keyof typeof limits] || 0
|
||||||
|
const canList = tier !== 'scout'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TerminalLayout
|
<TerminalLayout
|
||||||
@ -258,6 +260,26 @@ export default function MyListingsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
|
{/* Scout Paywall */}
|
||||||
|
{!canList && (
|
||||||
|
<div className="p-6 bg-gradient-to-br from-accent/10 to-transparent border border-accent/20 rounded-2xl text-center">
|
||||||
|
<Shield className="w-12 h-12 text-accent mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-semibold text-foreground mb-2">Upgrade to List Domains</h2>
|
||||||
|
<p className="text-foreground-muted mb-6 max-w-md mx-auto">
|
||||||
|
The Pounce marketplace is exclusive to Trader and Tycoon members.
|
||||||
|
List your domains, get verified, and sell directly to buyers with 0% commission.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/pricing"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-semibold rounded-xl hover:bg-accent/90 transition-all"
|
||||||
|
>
|
||||||
|
Upgrade to Trader • $9/mo
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
|
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
|
||||||
@ -275,7 +297,8 @@ export default function MyListingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats - only show if can list */}
|
||||||
|
{canList && (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard title="My Listings" value={`${listings.length}/${maxListings}`} icon={Tag} />
|
<StatCard title="My Listings" value={`${listings.length}/${maxListings}`} icon={Tag} />
|
||||||
<StatCard
|
<StatCard
|
||||||
@ -295,9 +318,11 @@ export default function MyListingsPage() {
|
|||||||
icon={MessageSquare}
|
icon={MessageSquare}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Listings */}
|
{/* Listings */}
|
||||||
{loading ? (
|
{canList && (
|
||||||
|
loading ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
@ -405,6 +430,7 @@ export default function MyListingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { TerminalLayout } from '@/components/TerminalLayout'
|
import { TerminalLayout } from '@/components/TerminalLayout'
|
||||||
import {
|
import {
|
||||||
PremiumTable,
|
PremiumTable,
|
||||||
Badge,
|
Badge,
|
||||||
PlatformBadge,
|
|
||||||
StatCard,
|
StatCard,
|
||||||
PageContainer,
|
PageContainer,
|
||||||
SearchInput,
|
SearchInput,
|
||||||
TabBar,
|
|
||||||
FilterBar,
|
FilterBar,
|
||||||
SelectDropdown,
|
SelectDropdown,
|
||||||
ActionButton,
|
ActionButton,
|
||||||
@ -32,10 +30,15 @@ import {
|
|||||||
Crown,
|
Crown,
|
||||||
Plus,
|
Plus,
|
||||||
Check,
|
Check,
|
||||||
|
Diamond,
|
||||||
|
Store,
|
||||||
|
Filter,
|
||||||
|
ShoppingBag,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
// Types
|
||||||
interface Auction {
|
interface Auction {
|
||||||
domain: string
|
domain: string
|
||||||
platform: string
|
platform: string
|
||||||
@ -53,72 +56,99 @@ interface Auction {
|
|||||||
affiliate_url: string
|
affiliate_url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Opportunity {
|
interface PounceDirectListing {
|
||||||
auction: Auction
|
id: number
|
||||||
analysis: {
|
domain: string
|
||||||
opportunity_score: number
|
price: number
|
||||||
urgency?: string
|
is_negotiable: boolean
|
||||||
competition?: string
|
verified: boolean
|
||||||
price_range?: string
|
seller_name: string
|
||||||
recommendation: string
|
created_at: string
|
||||||
reasoning?: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'all' | 'ending' | 'hot' | 'opportunities'
|
interface MarketItem {
|
||||||
type SortField = 'ending' | 'bid_asc' | 'bid_desc' | 'bids' | 'score'
|
type: 'auction' | 'direct'
|
||||||
type FilterPreset = 'all' | 'no-trash' | 'short' | 'high-value' | 'low-competition'
|
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 = [
|
type FilterType = 'all' | 'hide-spam' | 'pounce-only'
|
||||||
{ value: 'All', label: 'All Sources' },
|
|
||||||
{ value: 'GoDaddy', label: 'GoDaddy' },
|
const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'net', 'org', 'app', 'dev', 'ch']
|
||||||
{ value: 'Sedo', label: 'Sedo' },
|
|
||||||
{ value: 'NameJet', label: 'NameJet' },
|
const TLD_OPTIONS = [
|
||||||
{ value: 'DropCatch', label: 'DropCatch' },
|
{ 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 }[] = [
|
const PRICE_OPTIONS = [
|
||||||
{ id: 'all', label: 'All', icon: Gavel, description: 'Show all auctions' },
|
{ value: 'all', label: 'Any Price' },
|
||||||
{ id: 'no-trash', label: 'No Trash', icon: Sparkles, description: 'Clean domains only (no spam)', proOnly: true },
|
{ value: '100', label: '< $100' },
|
||||||
{ id: 'short', label: 'Short', icon: Zap, description: '4-letter domains or less' },
|
{ value: '500', label: '< $500' },
|
||||||
{ id: 'high-value', label: 'High Value', icon: Crown, description: 'Premium TLDs with high activity', proOnly: true },
|
{ value: '1000', label: '< $1,000' },
|
||||||
{ id: 'low-competition', label: 'Low Competition', icon: Target, description: 'Under 5 bids' },
|
{ 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]
|
||||||
|
|
||||||
// Pure functions (no hooks needed)
|
// Domain length bonus
|
||||||
function isCleanDomain(auction: Auction): boolean {
|
if (name.length <= 3) score += 30
|
||||||
const name = auction.domain.split('.')[0]
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.includes('-')) return false
|
||||||
if (name.length > 4 && /\d/.test(name)) return false
|
if (name.length > 4 && /\d/.test(name)) return false
|
||||||
if (name.length > 12) 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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateDealScore(auction: Auction): number {
|
// Format currency
|
||||||
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'
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@ -128,74 +158,85 @@ const formatCurrency = (value: number) => {
|
|||||||
}).format(value)
|
}).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 (
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 bg-accent/10 border border-accent/30 rounded-lg">
|
||||||
|
<Diamond className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span className="text-xs font-semibold text-accent">Pounce</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className={clsx('flex items-center gap-1.5 px-2 py-1 border rounded-lg', colors[source] || 'text-foreground-muted bg-foreground/5 border-border/30')}>
|
||||||
|
<Store className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-xs font-medium">{source}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarketPage() {
|
||||||
const { isAuthenticated, subscription } = useStore()
|
const { isAuthenticated, subscription } = useStore()
|
||||||
|
|
||||||
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
|
const [auctions, setAuctions] = useState<Auction[]>([])
|
||||||
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
|
const [directListings, setDirectListings] = useState<PounceDirectListing[]>([])
|
||||||
const [hotAuctions, setHotAuctions] = useState<Auction[]>([])
|
|
||||||
const [opportunities, setOpportunities] = useState<Opportunity[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('all')
|
|
||||||
const [sortBy, setSortBy] = useState<SortField>('ending')
|
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
|
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
|
const [hideSpam, setHideSpam] = useState(true) // Default: ON (as per concept)
|
||||||
|
const [pounceOnly, setPounceOnly] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [selectedPlatform, setSelectedPlatform] = useState('All')
|
const [selectedTld, setSelectedTld] = useState('all')
|
||||||
const [maxBid, setMaxBid] = useState('')
|
const [selectedPrice, setSelectedPrice] = useState('all')
|
||||||
const [filterPreset, setFilterPreset] = useState<FilterPreset>('all')
|
|
||||||
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
|
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
|
||||||
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
|
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
|
||||||
|
|
||||||
const isPaidUser = subscription?.tier === 'trader' || subscription?.tier === 'tycoon'
|
const isPaidUser = subscription?.tier === 'trader' || subscription?.tier === 'tycoon'
|
||||||
|
|
||||||
// Data loading
|
// Load data
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [auctionsData, hotData, endingData] = await Promise.all([
|
const [auctionsData, listingsData] = await Promise.all([
|
||||||
api.getAuctions(),
|
api.getAuctions().catch(() => ({ auctions: [] })),
|
||||||
api.getHotAuctions(50),
|
api.getMarketplaceListings().catch(() => ({ listings: [] })),
|
||||||
api.getEndingSoonAuctions(24, 50),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
setAllAuctions(auctionsData.auctions || [])
|
setAuctions(auctionsData.auctions || [])
|
||||||
setHotAuctions(hotData || [])
|
setDirectListings(listingsData.listings || [])
|
||||||
setEndingSoon(endingData || [])
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load auction data:', error)
|
console.error('Failed to load market data:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [loadData])
|
}, [loadData])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAuthenticated && opportunities.length === 0) {
|
|
||||||
loadOpportunities()
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, opportunities.length, loadOpportunities])
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
const handleRefresh = useCallback(async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
await loadData()
|
await loadData()
|
||||||
if (isAuthenticated) await loadOpportunities()
|
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
}, [loadData, loadOpportunities, isAuthenticated])
|
}, [loadData])
|
||||||
|
|
||||||
const handleTrackDomain = useCallback(async (domain: string) => {
|
const handleTrackDomain = useCallback(async (domain: string) => {
|
||||||
if (trackedDomains.has(domain)) return
|
if (trackedDomains.has(domain)) return
|
||||||
@ -211,262 +252,249 @@ export default function AuctionsPage() {
|
|||||||
}
|
}
|
||||||
}, [trackedDomains])
|
}, [trackedDomains])
|
||||||
|
|
||||||
const handleSort = useCallback((field: string) => {
|
// Combine and filter market items
|
||||||
const f = field as SortField
|
const marketItems = useMemo(() => {
|
||||||
if (sortBy === f) {
|
const items: MarketItem[] = []
|
||||||
setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
|
|
||||||
} else {
|
|
||||||
setSortBy(f)
|
|
||||||
setSortDirection('asc')
|
|
||||||
}
|
|
||||||
}, [sortBy])
|
|
||||||
|
|
||||||
// Memoized tabs
|
// Add auctions (unless pounceOnly is active)
|
||||||
const tabs = useMemo(() => [
|
if (!pounceOnly) {
|
||||||
{ id: 'all', label: 'All', icon: Gavel, count: allAuctions.length },
|
auctions.forEach(auction => {
|
||||||
{ id: 'ending', label: 'Ending Soon', icon: Timer, count: endingSoon.length, color: 'warning' as const },
|
const score = calculatePounceScore(auction.domain, auction.tld, auction.num_bids, auction.age_years || undefined)
|
||||||
{ 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
|
// Apply spam filter
|
||||||
const sortedAuctions = useMemo(() => {
|
if (hideSpam && !isCleanDomain(auction.domain, auction.tld)) return
|
||||||
// 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
|
items.push({
|
||||||
const baseFilter = filterPreset === 'all' && isPaidUser ? 'no-trash' : filterPreset
|
type: 'auction',
|
||||||
switch (baseFilter) {
|
domain: auction.domain,
|
||||||
case 'no-trash': auctions = auctions.filter(isCleanDomain); break
|
price: auction.current_bid,
|
||||||
case 'short': auctions = auctions.filter(a => a.domain.split('.')[0].length <= 4); break
|
source: auction.platform,
|
||||||
case 'high-value': auctions = auctions.filter(a =>
|
sourceIcon: auction.platform.toLowerCase() as any,
|
||||||
PREMIUM_TLDS.slice(0, 3).includes(a.tld) && a.num_bids >= 5 && calculateDealScore(a) >= 70
|
status: 'auction',
|
||||||
); break
|
timeRemaining: auction.time_remaining,
|
||||||
case 'low-competition': auctions = auctions.filter(a => a.num_bids < 5); break
|
numBids: auction.num_bids,
|
||||||
}
|
pounceScore: score,
|
||||||
|
affiliateUrl: auction.affiliate_url,
|
||||||
// Apply search
|
isPounce: false,
|
||||||
if (searchQuery) {
|
ageYears: auction.age_years || undefined,
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return auctions
|
// Add Pounce Direct listings
|
||||||
}, [activeTab, allAuctions, endingSoon, hotAuctions, opportunities, filterPreset, isPaidUser, searchQuery, selectedPlatform, maxBid, sortBy, sortDirection])
|
directListings.forEach(listing => {
|
||||||
|
const tld = listing.domain.split('.').pop() || ''
|
||||||
|
const score = calculatePounceScore(listing.domain, tld)
|
||||||
|
|
||||||
// Subtitle
|
// Apply spam filter
|
||||||
const subtitle = useMemo(() => {
|
if (hideSpam && !isCleanDomain(listing.domain, tld)) return
|
||||||
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
|
items.push({
|
||||||
const getOpportunityData = useCallback((domain: string) => {
|
type: 'direct',
|
||||||
if (activeTab !== 'opportunities') return null
|
domain: listing.domain,
|
||||||
return opportunities.find(o => o.auction.domain === domain)?.analysis
|
price: listing.price,
|
||||||
}, [activeTab, opportunities])
|
source: 'Pounce',
|
||||||
|
sourceIcon: 'pounce',
|
||||||
|
status: 'instant',
|
||||||
|
pounceScore: score + 10, // Bonus for verified listings
|
||||||
|
isPounce: true,
|
||||||
|
verified: listing.verified,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Table columns - memoized
|
// 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])
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
|
||||||
|
// Table columns
|
||||||
const columns = useMemo(() => [
|
const columns = useMemo(() => [
|
||||||
{
|
{
|
||||||
key: 'domain',
|
key: 'domain',
|
||||||
header: 'Domain',
|
header: 'Domain',
|
||||||
sortable: true,
|
render: (item: MarketItem) => (
|
||||||
render: (a: Auction) => (
|
<div className={clsx(
|
||||||
<div>
|
"py-1",
|
||||||
<a
|
item.isPounce && "border-l-2 border-accent pl-3 -ml-3"
|
||||||
href={a.affiliate_url}
|
)}>
|
||||||
target="_blank"
|
<div className="flex items-center gap-2">
|
||||||
rel="noopener noreferrer"
|
{item.isPounce && <Diamond className="w-4 h-4 text-accent" />}
|
||||||
className="font-mono font-medium text-foreground hover:text-accent transition-colors"
|
<span className="font-mono font-semibold text-foreground">{item.domain}</span>
|
||||||
>
|
{item.verified && (
|
||||||
{a.domain}
|
<span className="text-xs bg-accent/20 text-accent px-1.5 py-0.5 rounded font-medium">✓ Verified</span>
|
||||||
</a>
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1 lg:hidden">
|
<div className="flex items-center gap-2 mt-1 lg:hidden">
|
||||||
<PlatformBadge platform={a.platform} />
|
<SourceBadge source={item.source} isPounce={item.isPounce} />
|
||||||
{a.age_years && <span className="text-xs text-foreground-subtle">{a.age_years}y</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'platform',
|
|
||||||
header: 'Platform',
|
|
||||||
hideOnMobile: true,
|
|
||||||
render: (a: Auction) => (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<PlatformBadge platform={a.platform} />
|
|
||||||
{a.age_years && (
|
|
||||||
<span className="text-xs text-foreground-subtle flex items-center gap-1">
|
|
||||||
<Clock className="w-3 h-3" /> {a.age_years}y
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bid_asc',
|
|
||||||
header: 'Bid',
|
|
||||||
sortable: true,
|
|
||||||
align: 'right' as const,
|
|
||||||
render: (a: Auction) => (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-foreground">{formatCurrency(a.current_bid)}</span>
|
|
||||||
{a.buy_now_price && (
|
|
||||||
<p className="text-xs text-accent">Buy: {formatCurrency(a.buy_now_price)}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'score',
|
key: 'score',
|
||||||
header: 'Deal Score',
|
header: 'Pounce Score',
|
||||||
sortable: true,
|
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
hideOnMobile: true,
|
render: (item: MarketItem) => {
|
||||||
render: (a: Auction) => {
|
if (!isPaidUser && !item.isPounce) {
|
||||||
if (activeTab === 'opportunities') {
|
|
||||||
const oppData = getOpportunityData(a.domain)
|
|
||||||
if (oppData) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center justify-center w-9 h-9 bg-accent/10 text-accent font-bold rounded-lg">
|
|
||||||
{oppData.opportunity_score}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPaidUser) {
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
className="inline-flex items-center justify-center w-9 h-9 bg-foreground/5 text-foreground-subtle rounded-lg hover:bg-accent/10 hover:text-accent transition-all"
|
className="inline-flex items-center justify-center w-10 h-10 bg-foreground/5 text-foreground-subtle rounded-xl hover:bg-accent/10 hover:text-accent transition-all"
|
||||||
title="Upgrade to see Deal Score"
|
title="Upgrade to see Pounce Score"
|
||||||
>
|
>
|
||||||
<Crown className="w-4 h-4" />
|
<Crown className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const score = calculateDealScore(a)
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex flex-col items-center">
|
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"inline-flex items-center justify-center w-9 h-9 rounded-lg font-bold text-sm",
|
"inline-flex items-center justify-center w-10 h-10 rounded-xl font-bold text-sm",
|
||||||
score >= 75 ? "bg-accent/20 text-accent" :
|
getScoreColor(item.pounceScore)
|
||||||
score >= 50 ? "bg-amber-500/20 text-amber-400" :
|
)}>
|
||||||
|
{item.pounceScore}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'price',
|
||||||
|
header: 'Price / Bid',
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (item: MarketItem) => (
|
||||||
|
<div>
|
||||||
|
<span className={clsx(
|
||||||
|
"font-semibold",
|
||||||
|
item.type === 'auction' ? "text-foreground-muted" : "text-foreground"
|
||||||
|
)}>
|
||||||
|
{formatCurrency(item.price)}
|
||||||
|
</span>
|
||||||
|
{item.type === 'auction' && (
|
||||||
|
<span className="text-xs text-foreground-subtle ml-1">(Bid)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status / Time',
|
||||||
|
align: 'center' as const,
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (item: MarketItem) => {
|
||||||
|
if (item.type === 'direct') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-1.5 px-3 py-1.5 bg-accent/20 text-accent rounded-lg">
|
||||||
|
<Zap className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-xs font-bold">Instant</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUrgent = item.timeRemaining?.includes('m') && !item.timeRemaining?.includes('h')
|
||||||
|
const isWarning = item.timeRemaining?.includes('h') && parseInt(item.timeRemaining) < 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx(
|
||||||
|
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg",
|
||||||
|
isUrgent ? "bg-red-400/20 text-red-400" :
|
||||||
|
isWarning ? "bg-amber-400/20 text-amber-400" :
|
||||||
"bg-foreground/10 text-foreground-muted"
|
"bg-foreground/10 text-foreground-muted"
|
||||||
)}>
|
)}>
|
||||||
{score}
|
<Timer className="w-3.5 h-3.5" />
|
||||||
</span>
|
<span className="text-xs font-medium">{item.timeRemaining}</span>
|
||||||
{score >= 75 && <span className="text-[10px] text-accent mt-0.5 font-medium">Undervalued</span>}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'bids',
|
key: 'source',
|
||||||
header: 'Bids',
|
header: 'Source',
|
||||||
sortable: true,
|
align: 'center' as const,
|
||||||
align: 'right' as const,
|
|
||||||
hideOnMobile: true,
|
hideOnMobile: true,
|
||||||
render: (a: Auction) => (
|
render: (item: MarketItem) => <SourceBadge source={item.source} isPounce={item.isPounce} />,
|
||||||
<span className={clsx(
|
|
||||||
"font-medium flex items-center justify-end gap-1",
|
|
||||||
a.num_bids >= 20 ? "text-accent" : a.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted"
|
|
||||||
)}>
|
|
||||||
{a.num_bids}
|
|
||||||
{a.num_bids >= 20 && <Flame className="w-3 h-3" />}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ending',
|
|
||||||
header: 'Time Left',
|
|
||||||
sortable: true,
|
|
||||||
align: 'right' as const,
|
|
||||||
hideOnMobile: true,
|
|
||||||
render: (a: Auction) => (
|
|
||||||
<span className={clsx("font-medium", getTimeColor(a.time_remaining))}>
|
|
||||||
{a.time_remaining}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
align: 'right' as const,
|
align: 'right' as const,
|
||||||
render: (a: Auction) => (
|
render: (item: MarketItem) => (
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.preventDefault(); handleTrackDomain(a.domain) }}
|
onClick={(e) => { e.preventDefault(); handleTrackDomain(item.domain) }}
|
||||||
disabled={trackedDomains.has(a.domain) || trackingInProgress === a.domain}
|
disabled={trackedDomains.has(item.domain) || trackingInProgress === item.domain}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"inline-flex items-center justify-center w-8 h-8 rounded-lg transition-all",
|
"inline-flex items-center justify-center w-9 h-9 rounded-xl transition-all",
|
||||||
trackedDomains.has(a.domain)
|
trackedDomains.has(item.domain)
|
||||||
? "bg-accent/20 text-accent cursor-default"
|
? "bg-accent/20 text-accent cursor-default"
|
||||||
: "bg-foreground/5 text-foreground-subtle hover:bg-accent/10 hover:text-accent"
|
: "bg-foreground/5 text-foreground-subtle hover:bg-accent/10 hover:text-accent"
|
||||||
)}
|
)}
|
||||||
title={trackedDomains.has(a.domain) ? 'Already tracked' : 'Add to Watchlist'}
|
title={trackedDomains.has(item.domain) ? 'Already tracked' : 'Add to Watchlist'}
|
||||||
>
|
>
|
||||||
{trackingInProgress === a.domain ? (
|
{trackingInProgress === item.domain ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
) : trackedDomains.has(a.domain) ? (
|
) : trackedDomains.has(item.domain) ? (
|
||||||
<Check className="w-4 h-4" />
|
<Check className="w-4 h-4" />
|
||||||
) : (
|
) : (
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href={a.affiliate_url}
|
href={item.affiliateUrl || `/buy/${item.domain}`}
|
||||||
target="_blank"
|
target={item.isPounce ? '_self' : '_blank'}
|
||||||
rel="noopener noreferrer"
|
rel={item.isPounce ? undefined : 'noopener noreferrer'}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background text-xs font-medium rounded-lg hover:bg-foreground/90 transition-all"
|
className={clsx(
|
||||||
|
"inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold rounded-xl transition-all",
|
||||||
|
item.isPounce
|
||||||
|
? "bg-accent text-background hover:bg-accent/90"
|
||||||
|
: "bg-foreground text-background hover:bg-foreground/90"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Bid <ExternalLink className="w-3 h-3" />
|
{item.isPounce ? 'Buy' : 'Bid'}
|
||||||
|
{!item.isPounce && <ExternalLink className="w-3 h-3" />}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
], [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 (
|
return (
|
||||||
<TerminalLayout
|
<TerminalLayout
|
||||||
title="Auctions"
|
title="Market"
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
actions={
|
actions={
|
||||||
<ActionButton onClick={handleRefresh} disabled={refreshing} variant="ghost" icon={refreshing ? Loader2 : RefreshCw}>
|
<ActionButton onClick={handleRefresh} disabled={refreshing} variant="ghost" icon={refreshing ? Loader2 : RefreshCw}>
|
||||||
@ -477,66 +505,60 @@ export default function AuctionsPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard title="All Auctions" value={allAuctions.length} icon={Gavel} />
|
<StatCard title="Total Listings" value={stats.total} icon={ShoppingBag} />
|
||||||
<StatCard title="Ending Soon" value={endingSoon.length} icon={Timer} />
|
<StatCard
|
||||||
<StatCard title="Hot Auctions" value={hotAuctions.length} subtitle="20+ bids" icon={Flame} />
|
title="Pounce Direct"
|
||||||
<StatCard title="Opportunities" value={opportunities.length} icon={Target} />
|
value={stats.direct}
|
||||||
|
icon={Diamond}
|
||||||
|
accent={stats.direct > 0}
|
||||||
|
/>
|
||||||
|
<StatCard title="Auctions" value={stats.auctions} icon={Gavel} />
|
||||||
|
<StatCard
|
||||||
|
title="High Score"
|
||||||
|
value={stats.highScore}
|
||||||
|
subtitle="Score ≥80"
|
||||||
|
icon={Sparkles}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Filter Toggles (as per concept) */}
|
||||||
<TabBar tabs={tabs} activeTab={activeTab} onChange={(id) => setActiveTab(id as TabType)} />
|
<div className="flex flex-wrap items-center gap-3 p-4 bg-background-secondary/40 border border-border/30 rounded-2xl">
|
||||||
|
<span className="text-sm font-medium text-foreground-muted flex items-center gap-2">
|
||||||
|
<Filter className="w-4 h-4" /> Filters:
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Smart Filter Presets */}
|
{/* Hide Spam Toggle - Default ON */}
|
||||||
<div className="flex flex-wrap gap-2 p-1.5 bg-background-secondary/30 border border-border/30 rounded-xl">
|
|
||||||
{FILTER_PRESETS.map((preset) => {
|
|
||||||
const isDisabled = preset.proOnly && !isPaidUser
|
|
||||||
const isActive = filterPreset === preset.id
|
|
||||||
const Icon = preset.icon
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={preset.id}
|
onClick={() => setHideSpam(!hideSpam)}
|
||||||
onClick={() => !isDisabled && setFilterPreset(preset.id)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
title={isDisabled ? 'Upgrade to Trader to use this filter' : preset.description}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg transition-all",
|
"flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all",
|
||||||
isActive
|
hideSpam
|
||||||
? "bg-accent text-background shadow-md"
|
? "bg-accent text-background"
|
||||||
: isDisabled
|
: "bg-foreground/10 text-foreground-muted hover:bg-foreground/15"
|
||||||
? "text-foreground-subtle opacity-50 cursor-not-allowed"
|
|
||||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">{preset.label}</span>
|
Hide Spam
|
||||||
{preset.proOnly && !isPaidUser && <Crown className="w-3 h-3 text-amber-400" />}
|
{hideSpam && <Check className="w-3.5 h-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tier notification for Scout users */}
|
{/* Pounce Direct Only Toggle */}
|
||||||
{!isPaidUser && (
|
<button
|
||||||
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-center gap-3">
|
onClick={() => setPounceOnly(!pounceOnly)}
|
||||||
<div className="w-10 h-10 bg-amber-500/20 rounded-xl flex items-center justify-center shrink-0">
|
className={clsx(
|
||||||
<Eye className="w-5 h-5 text-amber-400" />
|
"flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all",
|
||||||
</div>
|
pounceOnly
|
||||||
<div className="flex-1">
|
? "bg-accent text-background"
|
||||||
<p className="text-sm font-medium text-foreground">You're seeing the raw auction feed</p>
|
: "bg-foreground/10 text-foreground-muted hover:bg-foreground/15"
|
||||||
<p className="text-xs text-foreground-muted">
|
|
||||||
Upgrade to Trader for spam-free listings, Deal Scores, and Smart Filters.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/pricing"
|
|
||||||
className="shrink-0 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg hover:bg-accent-hover transition-all"
|
|
||||||
>
|
|
||||||
Upgrade
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<Diamond className="w-4 h-4" />
|
||||||
|
Pounce Direct Only
|
||||||
|
{pounceOnly && <Check className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Search & Dropdowns */}
|
||||||
<FilterBar>
|
<FilterBar>
|
||||||
<SearchInput
|
<SearchInput
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@ -544,31 +566,38 @@ export default function AuctionsPage() {
|
|||||||
placeholder="Search domains..."
|
placeholder="Search domains..."
|
||||||
className="flex-1 min-w-[200px] max-w-md"
|
className="flex-1 min-w-[200px] max-w-md"
|
||||||
/>
|
/>
|
||||||
<SelectDropdown value={selectedPlatform} onChange={setSelectedPlatform} options={PLATFORMS} />
|
<SelectDropdown value={selectedTld} onChange={setSelectedTld} options={TLD_OPTIONS} />
|
||||||
<div className="relative">
|
<SelectDropdown value={selectedPrice} onChange={setSelectedPrice} options={PRICE_OPTIONS} />
|
||||||
<DollarSign className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="Max bid"
|
|
||||||
value={maxBid}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Upgrade Notice for Scout */}
|
||||||
|
{!isPaidUser && (
|
||||||
|
<div className="p-4 bg-accent/5 border border-accent/20 rounded-2xl flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-accent/20 rounded-xl flex items-center justify-center shrink-0">
|
||||||
|
<Eye className="w-6 h-6 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold text-foreground">Upgrade for Full Market Intelligence</p>
|
||||||
|
<p className="text-xs text-foreground-muted mt-0.5">
|
||||||
|
See Pounce Scores for all domains, unlock advanced filters, and get notified on deals.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/pricing"
|
||||||
|
className="shrink-0 px-5 py-2.5 bg-accent text-background text-sm font-semibold rounded-xl hover:bg-accent/90 transition-all"
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Market Table */}
|
||||||
<PremiumTable
|
<PremiumTable
|
||||||
data={sortedAuctions}
|
data={marketItems}
|
||||||
keyExtractor={(a) => `${a.domain}-${a.platform}`}
|
keyExtractor={(item) => `${item.domain}-${item.source}`}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
sortBy={sortBy}
|
emptyIcon={<ShoppingBag className="w-12 h-12 text-foreground-subtle" />}
|
||||||
sortDirection={sortDirection}
|
emptyTitle={searchQuery ? `No listings matching "${searchQuery}"` : "No listings found"}
|
||||||
onSort={handleSort}
|
|
||||||
emptyIcon={<Gavel className="w-12 h-12 text-foreground-subtle" />}
|
|
||||||
emptyTitle={searchQuery ? `No auctions matching "${searchQuery}"` : "No auctions found"}
|
|
||||||
emptyDescription="Try adjusting your filters or check back later"
|
emptyDescription="Try adjusting your filters or check back later"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,24 +5,28 @@ import { useSearchParams } from 'next/navigation'
|
|||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { TerminalLayout } from '@/components/TerminalLayout'
|
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 { Toast, useToast } from '@/components/Toast'
|
||||||
import {
|
import {
|
||||||
Eye,
|
Eye,
|
||||||
Briefcase,
|
|
||||||
TrendingUp,
|
|
||||||
Gavel,
|
Gavel,
|
||||||
|
Tag,
|
||||||
Clock,
|
Clock,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ChevronRight,
|
|
||||||
Plus,
|
Plus,
|
||||||
Zap,
|
Zap,
|
||||||
Crown,
|
Crown,
|
||||||
Activity,
|
Activity,
|
||||||
Loader2,
|
|
||||||
Search,
|
|
||||||
Bell,
|
Bell,
|
||||||
|
Search,
|
||||||
|
TrendingUp,
|
||||||
|
ArrowRight,
|
||||||
|
Globe,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@ -42,23 +46,34 @@ interface TrendingTld {
|
|||||||
reason: string
|
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 searchParams = useSearchParams()
|
||||||
const {
|
const {
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isLoading,
|
isLoading,
|
||||||
user,
|
user,
|
||||||
domains,
|
domains,
|
||||||
subscription
|
subscription,
|
||||||
|
addDomain,
|
||||||
} = useStore()
|
} = useStore()
|
||||||
|
|
||||||
const { toast, showToast, hideToast } = useToast()
|
const { toast, showToast, hideToast } = useToast()
|
||||||
const [hotAuctions, setHotAuctions] = useState<HotAuction[]>([])
|
const [hotAuctions, setHotAuctions] = useState<HotAuction[]>([])
|
||||||
const [trendingTlds, setTrendingTlds] = useState<TrendingTld[]>([])
|
const [trendingTlds, setTrendingTlds] = useState<TrendingTld[]>([])
|
||||||
const [loadingAuctions, setLoadingAuctions] = useState(true)
|
const [loadingData, setLoadingData] = useState(true)
|
||||||
const [loadingTlds, setLoadingTlds] = useState(true)
|
|
||||||
const [quickDomain, setQuickDomain] = useState('')
|
// Universal Search State
|
||||||
const [addingDomain, setAddingDomain] = useState(false)
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchResult, setSearchResult] = useState<SearchResult | null>(null)
|
||||||
|
const [addingToWatchlist, setAddingToWatchlist] = useState(false)
|
||||||
|
|
||||||
// Check for upgrade success
|
// Check for upgrade success
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -66,7 +81,7 @@ export default function DashboardPage() {
|
|||||||
showToast('Welcome to your upgraded plan! 🎉', 'success')
|
showToast('Welcome to your upgraded plan! 🎉', 'success')
|
||||||
window.history.replaceState({}, '', '/terminal/radar')
|
window.history.replaceState({}, '', '/terminal/radar')
|
||||||
}
|
}
|
||||||
}, [searchParams])
|
}, [searchParams, showToast])
|
||||||
|
|
||||||
const loadDashboardData = useCallback(async () => {
|
const loadDashboardData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -75,41 +90,87 @@ export default function DashboardPage() {
|
|||||||
api.getTrendingTlds().catch(() => ({ trending: [] }))
|
api.getTrendingTlds().catch(() => ({ trending: [] }))
|
||||||
])
|
])
|
||||||
setHotAuctions(auctions.slice(0, 5))
|
setHotAuctions(auctions.slice(0, 5))
|
||||||
setTrendingTlds(trending.trending?.slice(0, 4) || [])
|
setTrendingTlds(trending.trending?.slice(0, 6) || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load dashboard data:', error)
|
console.error('Failed to load dashboard data:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAuctions(false)
|
setLoadingData(false)
|
||||||
setLoadingTlds(false)
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load dashboard data
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
loadDashboardData()
|
loadDashboardData()
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, loadDashboardData])
|
}, [isAuthenticated, loadDashboardData])
|
||||||
|
|
||||||
const handleQuickAdd = useCallback(async (e: React.FormEvent) => {
|
// Universal Search - simultaneous check
|
||||||
e.preventDefault()
|
const handleSearch = useCallback(async (domain: string) => {
|
||||||
if (!quickDomain.trim()) return
|
if (!domain.trim()) {
|
||||||
|
setSearchResult(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanDomain = domain.trim().toLowerCase()
|
||||||
|
setSearchResult({ available: null, inAuction: false, inMarketplace: false, loading: true })
|
||||||
|
|
||||||
setAddingDomain(true)
|
|
||||||
try {
|
try {
|
||||||
const store = useStore.getState()
|
// Parallel checks
|
||||||
await store.addDomain(quickDomain.trim())
|
const [whoisResult, auctionsResult] = await Promise.all([
|
||||||
setQuickDomain('')
|
api.checkDomain(cleanDomain, true).catch(() => null),
|
||||||
showToast(`Added ${quickDomain.trim()} to watchlist`, 'success')
|
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) {
|
} catch (err: any) {
|
||||||
showToast(err.message || 'Failed to add domain', 'error')
|
showToast(err.message || 'Failed to add domain', 'error')
|
||||||
} finally {
|
} 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
|
// 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 availableDomains = domains?.filter(d => d.is_available) || []
|
||||||
const totalDomains = domains?.length || 0
|
const totalDomains = domains?.length || 0
|
||||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||||
@ -127,9 +188,15 @@ export default function DashboardPage() {
|
|||||||
subtitle = 'Start tracking domains to find opportunities'
|
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])
|
}, [domains, subscription])
|
||||||
|
|
||||||
|
// Generate ticker items
|
||||||
|
const tickerItems = useTickerItems(trendingTlds, availableDomains, hotAuctions)
|
||||||
|
|
||||||
if (isLoading || !isAuthenticated) {
|
if (isLoading || !isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
@ -145,88 +212,166 @@ export default function DashboardPage() {
|
|||||||
>
|
>
|
||||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||||
|
|
||||||
<PageContainer>
|
{/* A. THE TICKER - Market movements */}
|
||||||
{/* Quick Add */}
|
{tickerItems.length > 0 && (
|
||||||
<div className="relative p-5 sm:p-6 bg-gradient-to-r from-accent/10 via-accent/5 to-transparent border border-accent/20 rounded-2xl overflow-hidden">
|
<div className="-mx-4 sm:-mx-6 lg:-mx-8 mb-6">
|
||||||
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
<Ticker items={tickerItems} speed={40} />
|
||||||
<div className="relative">
|
|
||||||
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
|
|
||||||
<div className="w-8 h-8 bg-accent/20 rounded-lg flex items-center justify-center">
|
|
||||||
<Search className="w-4 h-4 text-accent" />
|
|
||||||
</div>
|
|
||||||
Quick Add to Watchlist
|
|
||||||
</h2>
|
|
||||||
<form onSubmit={handleQuickAdd} className="flex flex-col sm:flex-row gap-3">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={quickDomain}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={addingDomain || !quickDomain.trim()}
|
|
||||||
className="flex items-center justify-center gap-2 h-11 px-6 bg-gradient-to-r from-accent to-accent/80 text-background rounded-xl
|
|
||||||
font-medium hover:shadow-[0_0_20px_-5px_rgba(16,185,129,0.4)] transition-all
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span>Add</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats Overview */}
|
<PageContainer>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
{/* B. QUICK STATS - 3 Cards as per concept */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||||
<Link href="/terminal/watchlist" className="group">
|
<Link href="/terminal/watchlist" className="group">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Domains Watched"
|
title="Watching"
|
||||||
value={totalDomains}
|
value={totalDomains}
|
||||||
|
subtitle={availableDomains.length > 0 ? `${availableDomains.length} alerts` : undefined}
|
||||||
icon={Eye}
|
icon={Eye}
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
<Link href="/terminal/watchlist?filter=available" className="group">
|
|
||||||
<StatCard
|
|
||||||
title="Available Now"
|
|
||||||
value={availableDomains.length}
|
|
||||||
icon={Sparkles}
|
|
||||||
accent={availableDomains.length > 0}
|
accent={availableDomains.length > 0}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/terminal/watchlist" className="group">
|
<Link href="/terminal/market" className="group">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Watchlist"
|
title="Market"
|
||||||
value={totalDomains}
|
value={hotAuctions.length > 0 ? `${hotAuctions.length}+` : '0'}
|
||||||
icon={Briefcase}
|
subtitle="opportunities"
|
||||||
|
icon={Gavel}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/terminal/listing" className="group">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Plan"
|
title="My Listings"
|
||||||
value={tierName}
|
value={listingsCount}
|
||||||
subtitle={`${subscription?.domains_used || 0}/${subscription?.domain_limit || 5} slots`}
|
subtitle="active"
|
||||||
icon={TierIcon}
|
icon={Tag}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* C. UNIVERSAL SEARCH - Hero Element */}
|
||||||
|
<div className="relative p-6 sm:p-8 bg-gradient-to-br from-accent/10 via-accent/5 to-transparent border border-accent/20 rounded-2xl overflow-hidden mb-8">
|
||||||
|
<div className="absolute top-0 right-0 w-96 h-96 bg-accent/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 bg-accent/20 rounded-xl flex items-center justify-center">
|
||||||
|
<Search className="w-5 h-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Universal Search</h2>
|
||||||
|
<p className="text-sm text-foreground-muted">Check availability, auctions & marketplace simultaneously</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Globe className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => 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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Activity Feed + Market Pulse */}
|
{/* Search Results */}
|
||||||
|
{searchResult && (
|
||||||
|
<div className="mt-4 p-4 bg-background/60 backdrop-blur-sm border border-border/40 rounded-xl">
|
||||||
|
{searchResult.loading ? (
|
||||||
|
<div className="flex items-center gap-3 text-foreground-muted">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
<span>Checking...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Availability */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{searchResult.available === true ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-accent" />
|
||||||
|
) : searchResult.available === false ? (
|
||||||
|
<XCircle className="w-5 h-5 text-red-400" />
|
||||||
|
) : (
|
||||||
|
<Globe className="w-5 h-5 text-foreground-subtle" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{searchResult.available === true
|
||||||
|
? 'Available for registration!'
|
||||||
|
: searchResult.available === false
|
||||||
|
? 'Currently registered'
|
||||||
|
: 'Could not check availability'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{searchResult.available === true && (
|
||||||
|
<a
|
||||||
|
href={`https://www.namecheap.com/domains/registration/results/?domain=${searchQuery}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs font-medium text-accent hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Register Now <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* In Auction */}
|
||||||
|
{searchResult.inAuction && searchResult.auctionData && (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-amber-400/10 border border-amber-400/20 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Gavel className="w-5 h-5 text-amber-400" />
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
In auction: ${searchResult.auctionData.current_bid} ({searchResult.auctionData.time_remaining})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={searchResult.auctionData.affiliate_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs font-medium text-amber-400 hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Bid Now <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleAddToWatchlist}
|
||||||
|
disabled={addingToWatchlist}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-accent text-background rounded-lg font-medium
|
||||||
|
hover:bg-accent/90 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{addingToWatchlist ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Add to Watchlist
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* D. RECENT ALERTS + MARKET PULSE */}
|
||||||
<div className="grid lg:grid-cols-2 gap-6">
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
{/* Activity Feed */}
|
{/* Recent Alerts / Activity Feed */}
|
||||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
|
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
|
||||||
<div className="p-5 border-b border-border/30">
|
<div className="p-5 border-b border-border/30">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Activity Feed"
|
title="Recent Alerts"
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
compact
|
compact
|
||||||
action={
|
action={
|
||||||
<Link href="/terminal/watchlist" className="text-sm text-accent hover:text-accent/80 transition-colors">
|
<Link href="/terminal/watchlist" className="text-sm text-accent hover:text-accent/80 transition-colors flex items-center gap-1">
|
||||||
View all →
|
View all <ArrowRight className="w-3.5 h-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -234,7 +379,7 @@ export default function DashboardPage() {
|
|||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
{availableDomains.length > 0 ? (
|
{availableDomains.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{availableDomains.slice(0, 4).map((domain) => (
|
{availableDomains.slice(0, 5).map((domain) => (
|
||||||
<div
|
<div
|
||||||
key={domain.id}
|
key={domain.id}
|
||||||
className="flex items-center gap-4 p-3 bg-accent/5 border border-accent/20 rounded-xl"
|
className="flex items-center gap-4 p-3 bg-accent/5 border border-accent/20 rounded-xl"
|
||||||
@ -244,7 +389,7 @@ export default function DashboardPage() {
|
|||||||
<span className="absolute inset-0 bg-accent rounded-full animate-ping opacity-50" />
|
<span className="absolute inset-0 bg-accent rounded-full animate-ping opacity-50" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-foreground truncate">{domain.name}</p>
|
<p className="text-sm font-medium text-foreground font-mono truncate">{domain.name}</p>
|
||||||
<p className="text-xs text-accent">Available for registration!</p>
|
<p className="text-xs text-accent">Available for registration!</p>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
@ -257,18 +402,13 @@ export default function DashboardPage() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{availableDomains.length > 4 && (
|
|
||||||
<p className="text-center text-sm text-foreground-muted">
|
|
||||||
+{availableDomains.length - 4} more available
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : totalDomains > 0 ? (
|
) : totalDomains > 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Bell className="w-10 h-10 text-foreground-subtle mx-auto mb-3" />
|
<Bell className="w-10 h-10 text-foreground-subtle mx-auto mb-3" />
|
||||||
<p className="text-foreground-muted">All domains are still registered</p>
|
<p className="text-foreground-muted">All domains are still registered</p>
|
||||||
<p className="text-sm text-foreground-subtle mt-1">
|
<p className="text-sm text-foreground-subtle mt-1">
|
||||||
We're monitoring {totalDomains} domains for you
|
Monitoring {totalDomains} domains for you
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -276,7 +416,7 @@ export default function DashboardPage() {
|
|||||||
<Plus className="w-10 h-10 text-foreground-subtle mx-auto mb-3" />
|
<Plus className="w-10 h-10 text-foreground-subtle mx-auto mb-3" />
|
||||||
<p className="text-foreground-muted">No domains tracked yet</p>
|
<p className="text-foreground-muted">No domains tracked yet</p>
|
||||||
<p className="text-sm text-foreground-subtle mt-1">
|
<p className="text-sm text-foreground-subtle mt-1">
|
||||||
Add a domain above to start monitoring
|
Use Universal Search above to start
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -291,14 +431,14 @@ export default function DashboardPage() {
|
|||||||
icon={Gavel}
|
icon={Gavel}
|
||||||
compact
|
compact
|
||||||
action={
|
action={
|
||||||
<Link href="/terminal/market" className="text-sm text-accent hover:text-accent/80 transition-colors">
|
<Link href="/terminal/market" className="text-sm text-accent hover:text-accent/80 transition-colors flex items-center gap-1">
|
||||||
View all →
|
View all <ArrowRight className="w-3.5 h-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
{loadingAuctions ? (
|
{loadingData ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<div key={i} className="h-14 bg-foreground/5 rounded-xl animate-pulse" />
|
<div key={i} className="h-14 bg-foreground/5 rounded-xl animate-pulse" />
|
||||||
@ -316,7 +456,7 @@ export default function DashboardPage() {
|
|||||||
hover:bg-foreground/10 transition-colors group"
|
hover:bg-foreground/10 transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-foreground truncate">{auction.domain}</p>
|
<p className="text-sm font-medium text-foreground font-mono truncate">{auction.domain}</p>
|
||||||
<p className="text-xs text-foreground-muted flex items-center gap-2">
|
<p className="text-xs text-foreground-muted flex items-center gap-2">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{auction.time_remaining}
|
{auction.time_remaining}
|
||||||
@ -325,7 +465,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-semibold text-foreground">${auction.current_bid}</p>
|
<p className="text-sm font-semibold text-foreground">${auction.current_bid}</p>
|
||||||
<p className="text-xs text-foreground-subtle">current bid</p>
|
<p className="text-xs text-foreground-subtle">bid</p>
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink className="w-4 h-4 text-foreground-subtle group-hover:text-foreground" />
|
<ExternalLink className="w-4 h-4 text-foreground-subtle group-hover:text-foreground" />
|
||||||
</a>
|
</a>
|
||||||
@ -340,63 +480,6 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trending TLDs */}
|
|
||||||
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
|
|
||||||
<div className="p-5 border-b border-border/30">
|
|
||||||
<SectionHeader
|
|
||||||
title="Trending TLDs"
|
|
||||||
icon={TrendingUp}
|
|
||||||
compact
|
|
||||||
action={
|
|
||||||
<Link href="/terminal/intel" className="text-sm text-accent hover:text-accent/80 transition-colors">
|
|
||||||
View all →
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="p-5">
|
|
||||||
{loadingTlds ? (
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{[...Array(4)].map((_, i) => (
|
|
||||||
<div key={i} className="h-24 bg-foreground/5 rounded-xl animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : trendingTlds.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{trendingTlds.map((tld) => (
|
|
||||||
<Link
|
|
||||||
key={tld.tld}
|
|
||||||
href={`/tld-pricing/${tld.tld}`}
|
|
||||||
className="group relative p-4 bg-foreground/5 border border-border/30 rounded-xl
|
|
||||||
hover:border-accent/30 transition-all duration-300 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
||||||
<div className="relative">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<span className="font-mono text-2xl font-semibold text-foreground group-hover:text-accent transition-colors">.{tld.tld}</span>
|
|
||||||
<span className={clsx(
|
|
||||||
"text-xs font-bold px-2.5 py-1 rounded-lg border",
|
|
||||||
(tld.price_change || 0) > 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)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-foreground-muted truncate">{tld.reason}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<TrendingUp className="w-10 h-10 text-foreground-subtle mx-auto mb-3" />
|
|
||||||
<p className="text-foreground-muted">No trending TLDs available</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</TerminalLayout>
|
</TerminalLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -37,52 +37,59 @@ import {
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
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<HealthStatus, {
|
const healthStatusConfig: Record<HealthStatus, {
|
||||||
label: string
|
label: string
|
||||||
color: string
|
color: string
|
||||||
bgColor: string
|
bgColor: string
|
||||||
icon: typeof Activity
|
icon: typeof Activity
|
||||||
description: string
|
description: string
|
||||||
|
ampel: '🟢' | '🟡' | '🔴' | '⚪'
|
||||||
}> = {
|
}> = {
|
||||||
healthy: {
|
healthy: {
|
||||||
label: 'Healthy',
|
label: 'Online',
|
||||||
color: 'text-accent',
|
color: 'text-accent',
|
||||||
bgColor: 'bg-accent/10 border-accent/20',
|
bgColor: 'bg-accent/10 border-accent/20',
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
description: 'Domain is active and well-maintained'
|
description: 'Domain is active and well-maintained',
|
||||||
|
ampel: '🟢'
|
||||||
},
|
},
|
||||||
weakening: {
|
weakening: {
|
||||||
label: 'Weakening',
|
label: 'DNS Changed',
|
||||||
color: 'text-amber-400',
|
color: 'text-amber-400',
|
||||||
bgColor: 'bg-amber-400/10 border-amber-400/20',
|
bgColor: 'bg-amber-400/10 border-amber-400/20',
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
description: 'Warning signs detected - owner may be losing interest'
|
description: 'Warning signs detected - DNS or config changed',
|
||||||
|
ampel: '🟡'
|
||||||
},
|
},
|
||||||
parked: {
|
parked: {
|
||||||
label: 'For Sale',
|
label: 'For Sale',
|
||||||
color: 'text-orange-400',
|
color: 'text-orange-400',
|
||||||
bgColor: 'bg-orange-400/10 border-orange-400/20',
|
bgColor: 'bg-orange-400/10 border-orange-400/20',
|
||||||
icon: ShoppingCart,
|
icon: ShoppingCart,
|
||||||
description: 'Domain is parked and likely for sale'
|
description: 'Domain is parked and likely for sale',
|
||||||
|
ampel: '🟡'
|
||||||
},
|
},
|
||||||
critical: {
|
critical: {
|
||||||
label: 'Critical',
|
label: 'Offline',
|
||||||
color: 'text-red-400',
|
color: 'text-red-400',
|
||||||
bgColor: 'bg-red-400/10 border-red-400/20',
|
bgColor: 'bg-red-400/10 border-red-400/20',
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
description: 'Domain drop is imminent!'
|
description: 'Domain is offline or has critical errors',
|
||||||
|
ampel: '🔴'
|
||||||
},
|
},
|
||||||
unknown: {
|
unknown: {
|
||||||
label: 'Unknown',
|
label: 'Unknown',
|
||||||
color: 'text-foreground-muted',
|
color: 'text-foreground-muted',
|
||||||
bgColor: 'bg-foreground/5 border-border/30',
|
bgColor: 'bg-foreground/5 border-border/30',
|
||||||
icon: HelpCircle,
|
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() {
|
export default function WatchlistPage() {
|
||||||
const { domains, addDomain, deleteDomain, refreshDomain, subscription } = useStore()
|
const { domains, addDomain, deleteDomain, refreshDomain, subscription } = useStore()
|
||||||
@ -93,7 +100,7 @@ export default function WatchlistPage() {
|
|||||||
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
const [togglingNotifyId, setTogglingNotifyId] = useState<number | null>(null)
|
const [togglingNotifyId, setTogglingNotifyId] = useState<number | null>(null)
|
||||||
const [filterStatus, setFilterStatus] = useState<FilterStatus>('all')
|
const [filterStatus, setFilterStatus] = useState<FilterStatus>('watching')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
// Health check state
|
// Health check state
|
||||||
@ -120,16 +127,17 @@ export default function WatchlistPage() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (filterStatus === 'available' && !domain.is_available) 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
|
return true
|
||||||
})
|
})
|
||||||
}, [domains, searchQuery, filterStatus])
|
}, [domains, searchQuery, filterStatus])
|
||||||
|
|
||||||
// Memoized tabs config
|
// Memoized tabs config - as per concept: Watching + My Portfolio
|
||||||
const tabs = useMemo(() => [
|
const tabs = useMemo(() => [
|
||||||
{ id: 'all', label: 'All', count: stats.domainsUsed },
|
{ id: 'watching', label: 'Watching', icon: Eye, count: stats.domainsUsed },
|
||||||
{ id: 'available', label: 'Available', count: stats.availableCount, color: 'accent' as const },
|
{ id: 'portfolio', label: 'My Portfolio', icon: Shield, count: 0 }, // TODO: verified own domains
|
||||||
{ id: 'watching', label: 'Monitoring', count: stats.watchingCount },
|
{ id: 'available', label: 'Available', icon: Sparkles, count: stats.availableCount, color: 'accent' as const },
|
||||||
], [stats])
|
], [stats])
|
||||||
|
|
||||||
// Callbacks - prevent recreation on every render
|
// Callbacks - prevent recreation on every render
|
||||||
@ -234,18 +242,18 @@ export default function WatchlistPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'health',
|
||||||
header: 'Status',
|
header: 'Health',
|
||||||
align: 'left' as const,
|
align: 'center' as const,
|
||||||
|
width: '100px',
|
||||||
hideOnMobile: true,
|
hideOnMobile: true,
|
||||||
render: (domain: any) => {
|
render: (domain: any) => {
|
||||||
const health = healthReports[domain.id]
|
const health = healthReports[domain.id]
|
||||||
if (health) {
|
if (health) {
|
||||||
const config = healthStatusConfig[health.status]
|
const config = healthStatusConfig[health.status]
|
||||||
const Icon = config.icon
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("inline-flex items-center gap-2 px-2.5 py-1 rounded-lg border", config.bgColor)}>
|
<div className={clsx("inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border", config.bgColor)}>
|
||||||
<Icon className={clsx("w-3.5 h-3.5", config.color)} />
|
<span className="text-sm">{config.ampel}</span>
|
||||||
<span className={clsx("text-xs font-medium", config.color)}>{config.label}</span>
|
<span className={clsx("text-xs font-medium", config.color)}>{config.label}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -255,7 +263,7 @@ export default function WatchlistPage() {
|
|||||||
"text-sm",
|
"text-sm",
|
||||||
domain.is_available ? "text-accent font-medium" : "text-foreground-muted"
|
domain.is_available ? "text-accent font-medium" : "text-foreground-muted"
|
||||||
)}>
|
)}>
|
||||||
{domain.is_available ? 'Ready to pounce!' : 'Monitoring...'}
|
{domain.is_available ? '🟢 Available' : '⚪ Checking...'}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
165
frontend/src/components/Ticker.tsx
Normal file
165
frontend/src/components/Ticker.tsx
Normal file
@ -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<HTMLDivElement>(null)
|
||||||
|
const contentRef = useRef<HTMLDivElement>(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
|
||||||
|
? <TrendingUp className="w-3.5 h-3.5 text-orange-400" />
|
||||||
|
: <TrendingDown className="w-3.5 h-3.5 text-accent" />
|
||||||
|
case 'domain_available':
|
||||||
|
return <Sparkles className="w-3.5 h-3.5 text-accent" />
|
||||||
|
case 'auction_ending':
|
||||||
|
return <Gavel className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
case 'alert':
|
||||||
|
return <AlertCircle className="w-3.5 h-3.5 text-red-400" />
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative w-full overflow-hidden bg-gradient-to-r from-background-secondary/80 via-background-secondary/60 to-background-secondary/80
|
||||||
|
border-y border-border/30 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{/* Fade edges */}
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-16 bg-gradient-to-r from-background-secondary to-transparent z-10 pointer-events-none" />
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-background-secondary to-transparent z-10 pointer-events-none" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="flex items-center gap-8 py-2.5 px-4 whitespace-nowrap animate-ticker"
|
||||||
|
style={{
|
||||||
|
animationDuration: `${animationDuration}s`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tickerItems.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${item.id}-${idx}`}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-2 text-sm",
|
||||||
|
item.urgent && "font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getIcon(item.type, item.change)}
|
||||||
|
<span className="text-foreground-muted">{item.message}</span>
|
||||||
|
{item.value && (
|
||||||
|
<span className={clsx("font-mono font-medium", getValueColor(item.type, item.change))}>
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.change !== undefined && (
|
||||||
|
<span className={clsx(
|
||||||
|
"text-xs font-medium",
|
||||||
|
item.change > 0 ? "text-orange-400" : "text-accent"
|
||||||
|
)}>
|
||||||
|
{item.change > 0 ? '+' : ''}{item.change.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes ticker {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-ticker {
|
||||||
|
animation: ticker linear infinite;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
@ -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
|
// Subscription
|
||||||
async getSubscription() {
|
async getSubscription() {
|
||||||
return this.request<{
|
return this.request<{
|
||||||
|
|||||||
Reference in New Issue
Block a user