yves.gugger 806a5893af Final polish based on review feedback
- Landing: 'TLD price explorer' → 'Market overview'
- Auctions: Title to 'Curated Opportunities' (no small numbers)
- TLD Pricing: First row (.com) visible without blur for preview
- Footer: Updated branding, simplified, added tagline
- All Sign In links redirect back to original page
2025-12-10 09:03:23 +01:00

692 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useEffect, useState } from 'react'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import {
Clock,
TrendingUp,
ExternalLink,
Search,
Flame,
Timer,
Users,
ArrowUpRight,
Lock,
Gavel,
ChevronUp,
ChevronDown,
ChevronsUpDown,
DollarSign,
RefreshCw,
Target,
Info,
X,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
interface Auction {
domain: string
platform: string
platform_url: string
current_bid: number
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
tld: string
affiliate_url: string
}
interface Opportunity {
auction: Auction
analysis: {
opportunity_score: number
urgency?: string
competition?: string
price_range?: string
recommendation: string
reasoning?: string
// Legacy fields
estimated_value?: number
current_bid?: number
value_ratio?: number
potential_profit?: number
}
}
type TabType = 'all' | 'ending' | 'hot' | 'opportunities'
type SortField = 'ending' | 'bid_asc' | 'bid_desc' | 'bids'
const PLATFORMS = [
{ id: 'All', name: 'All Sources' },
{ id: 'GoDaddy', name: 'GoDaddy' },
{ id: 'Sedo', name: 'Sedo' },
{ id: 'NameJet', name: 'NameJet' },
{ id: 'DropCatch', name: 'DropCatch' },
{ id: 'ExpiredDomains', name: 'Expired Domains' },
]
const TAB_DESCRIPTIONS: Record<TabType, { title: string; description: string }> = {
all: {
title: 'All Auctions',
description: 'All active auctions from all platforms, sorted by ending time by default.',
},
ending: {
title: 'Ending Soon',
description: 'Auctions ending within the next 24 hours. Best for last-minute sniping opportunities.',
},
hot: {
title: 'Hot Auctions',
description: 'Auctions with the most bidding activity (20+ bids). High competition but proven demand.',
},
opportunities: {
title: 'Smart Opportunities',
description: 'Our algorithm scores auctions based on: Time urgency (ending soon = higher score), Competition (fewer bids = higher score), and Price point (lower entry = higher score). Only auctions with a combined score ≥ 3 are shown.',
},
}
function SortIcon({ field, currentField, direction }: { field: SortField, currentField: SortField, direction: 'asc' | 'desc' }) {
if (field !== currentField) {
return <ChevronsUpDown className="w-4 h-4 text-foreground-subtle" />
}
return direction === 'asc'
? <ChevronUp className="w-4 h-4 text-accent" />
: <ChevronDown className="w-4 h-4 text-accent" />
}
function getPlatformBadgeClass(platform: string) {
switch (platform) {
case 'GoDaddy': return 'text-blue-400 bg-blue-400/10'
case 'Sedo': return 'text-orange-400 bg-orange-400/10'
case 'NameJet': return 'text-purple-400 bg-purple-400/10'
case 'DropCatch': return 'text-teal-400 bg-teal-400/10'
default: return 'text-foreground-muted bg-foreground/5'
}
}
export default function AuctionsPage() {
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
const [hotAuctions, setHotAuctions] = useState<Auction[]>([])
const [opportunities, setOpportunities] = useState<Opportunity[]>([])
const [loading, setLoading] = useState(true)
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
const [searchQuery, setSearchQuery] = useState('')
const [selectedPlatform, setSelectedPlatform] = useState('All')
const [maxBid, setMaxBid] = useState<string>('')
useEffect(() => {
checkAuth()
loadData()
}, [checkAuth])
useEffect(() => {
if (isAuthenticated && opportunities.length === 0) {
loadOpportunities()
}
}, [isAuthenticated])
const loadOpportunities = async () => {
try {
const oppData = await api.getAuctionOpportunities()
setOpportunities(oppData.opportunities || [])
} catch (e) {
console.error('Failed to load opportunities:', e)
}
}
const loadData = async () => {
setLoading(true)
try {
const [auctionsData, hotData, endingData] = await Promise.all([
api.getAuctions(),
api.getHotAuctions(50),
api.getEndingSoonAuctions(24, 50),
])
setAllAuctions(auctionsData.auctions || [])
setHotAuctions(hotData || [])
setEndingSoon(endingData || [])
if (isAuthenticated) {
await loadOpportunities()
}
} catch (error) {
console.error('Failed to load auction data:', error)
} finally {
setLoading(false)
}
}
const handleRefresh = async () => {
setRefreshing(true)
await loadData()
setRefreshing(false)
}
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value)
}
const getCurrentAuctions = (): Auction[] => {
switch (activeTab) {
case 'ending': return endingSoon
case 'hot': return hotAuctions
case 'opportunities': return opportunities.map(o => o.auction)
default: return allAuctions
}
}
const getOpportunityData = (domain: string) => {
if (activeTab !== 'opportunities') return null
return opportunities.find(o => o.auction.domain === domain)?.analysis
}
const filteredAuctions = getCurrentAuctions().filter(auction => {
if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) {
return false
}
if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) {
return false
}
if (maxBid && auction.current_bid > parseFloat(maxBid)) {
return false
}
return true
})
const sortedAuctions = activeTab === 'opportunities'
? filteredAuctions
: [...filteredAuctions].sort((a, b) => {
const mult = sortDirection === 'asc' ? 1 : -1
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)
default:
return 0
}
})
const getTimeColor = (timeRemaining: string) => {
if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) {
return 'text-danger'
}
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) {
return 'text-warning'
}
return 'text-foreground-muted'
}
const handleSort = (field: SortField) => {
if (sortBy === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortBy(field)
setSortDirection('asc')
}
}
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)
}
return (
<div className="min-h-screen bg-background relative overflow-hidden">
{/* Background Effects - matching landing page */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
<div
className="absolute inset-0 opacity-[0.015]"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
backgroundSize: '64px 64px',
}}
/>
</div>
<Header />
<main className="relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 flex-1">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Auction Aggregator</span>
<h1 className="mt-4 font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] lg:text-[5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
Curated Opportunities
</h1>
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
Real-time from GoDaddy, Sedo, NameJet & DropCatch. Find opportunities.
</p>
</div>
{/* Login Banner for non-authenticated users */}
{!isAuthenticated && (
<div className="mb-8 p-5 bg-accent-muted border border-accent/20 rounded-xl flex flex-col sm:flex-row items-center justify-between gap-4 animate-fade-in">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-accent/20 rounded-xl flex items-center justify-center">
<Target className="w-5 h-5 text-accent" />
</div>
<div>
<p className="text-body-sm font-medium text-foreground">Unlock Smart Opportunities</p>
<p className="text-ui-sm text-foreground-muted">
Sign in for algorithmic deal-finding and alerts.
</p>
</div>
</div>
<Link
href="/register"
className="shrink-0 px-5 py-2.5 bg-accent text-background text-ui font-medium rounded-lg
hover:bg-accent-hover transition-all duration-300"
>
Hunt Free
</Link>
</div>
)}
{/* Tabs - flex-wrap to avoid horizontal scroll */}
<div className="mb-6 animate-slide-up">
<div className="flex flex-wrap items-center gap-2 mb-6">
<button
onClick={() => setActiveTab('all')}
title="View all active auctions from all platforms"
className={clsx(
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-lg transition-all",
activeTab === 'all'
? "bg-foreground text-background"
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary"
)}
>
<Gavel className="w-4 h-4" />
All
<span className={clsx(
"text-ui-xs px-1.5 py-0.5 rounded",
activeTab === 'all' ? "bg-background/20" : "bg-foreground/10"
)}>{allAuctions.length}</span>
</button>
<button
onClick={() => setActiveTab('ending')}
title="Auctions ending in the next 24 hours - best for sniping"
className={clsx(
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-lg transition-all",
activeTab === 'ending'
? "bg-warning text-background"
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary"
)}
>
<Timer className="w-4 h-4" />
Ending Soon
<span className={clsx(
"text-ui-xs px-1.5 py-0.5 rounded",
activeTab === 'ending' ? "bg-background/20" : "bg-foreground/10"
)}>{endingSoon.length}</span>
</button>
<button
onClick={() => setActiveTab('hot')}
title="Auctions with 20+ bids - high demand, proven interest"
className={clsx(
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-lg transition-all",
activeTab === 'hot'
? "bg-accent text-background"
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary"
)}
>
<Flame className="w-4 h-4" />
Hot
<span className={clsx(
"text-ui-xs px-1.5 py-0.5 rounded",
activeTab === 'hot' ? "bg-background/20" : "bg-foreground/10"
)}>{hotAuctions.length}</span>
</button>
<button
onClick={() => setActiveTab('opportunities')}
title="Smart algorithm: Time urgency × Competition × Price = Score"
className={clsx(
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-lg transition-all",
activeTab === 'opportunities'
? "bg-accent text-background"
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary"
)}
>
<Target className="w-4 h-4" />
Opportunities
{!isAuthenticated && <Lock className="w-3 h-3 ml-1" />}
{isAuthenticated && (
<span className={clsx(
"text-ui-xs px-1.5 py-0.5 rounded",
activeTab === 'opportunities' ? "bg-background/20" : "bg-foreground/10"
)}>{opportunities.length}</span>
)}
</button>
<button
onClick={handleRefresh}
disabled={refreshing}
title="Refresh auction data from all platforms"
className="ml-auto flex items-center gap-2 px-4 py-2.5 text-ui-sm text-foreground-muted hover:text-foreground hover:bg-background-secondary/50 rounded-lg transition-all disabled:opacity-50"
>
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
Refresh
</button>
</div>
</div>
{/* Search & Filters */}
<div className="mb-6 animate-slide-up">
<div className="flex flex-wrap gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
<input
type="text"
placeholder="Search domains..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-12 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl
text-body text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent
transition-all duration-300"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-foreground-subtle hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<select
value={selectedPlatform}
onChange={(e) => setSelectedPlatform(e.target.value)}
title="Filter by auction platform"
className="px-4 py-3 bg-background-secondary/50 border border-border rounded-xl
text-body text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent cursor-pointer transition-all"
>
{PLATFORMS.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<div className="relative">
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
<input
type="number"
placeholder="Max bid"
title="Filter auctions under this bid amount"
value={maxBid}
onChange={(e) => setMaxBid(e.target.value)}
className="w-32 pl-11 pr-4 py-3 bg-background-secondary/50 border border-border rounded-xl
text-body text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all"
/>
</div>
</div>
</div>
{/* Content */}
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : activeTab === 'opportunities' && !isAuthenticated ? (
<div className="text-center py-20 border border-dashed border-border rounded-2xl bg-background-secondary/20">
<div className="w-14 h-14 bg-accent/10 rounded-2xl flex items-center justify-center mx-auto mb-5">
<Target className="w-7 h-7 text-accent" />
</div>
<h3 className="text-body-lg font-medium text-foreground mb-2">Unlock Smart Opportunities</h3>
<p className="text-body-sm text-foreground-muted max-w-md mx-auto mb-6">
Our algorithm analyzes ending times, bid activity, and price points to find the best opportunities.
</p>
<Link
href="/register"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background text-ui font-medium rounded-xl
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
>
Get Started Free
<ArrowUpRight className="w-4 h-4" />
</Link>
</div>
) : (
/* Table - using proper <table> like TLD Prices */
<div className="bg-background-secondary/30 border border-border rounded-xl overflow-hidden animate-slide-up">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-background-secondary border-b border-border">
<th className="text-left px-4 sm:px-6 py-4">
<button
onClick={() => handleSort('ending')}
title="Sort by ending time"
className="flex items-center gap-2 text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
>
Domain
<SortIcon field="ending" currentField={sortBy} direction={sortDirection} />
</button>
</th>
<th className="text-left px-4 sm:px-6 py-4 hidden lg:table-cell">
<span className="text-ui-sm text-foreground-subtle font-medium">Platform</span>
</th>
<th className="text-right px-4 sm:px-6 py-4">
<button
onClick={() => handleSort('bid_asc')}
title="Current highest bid in USD"
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
>
Bid
<SortIcon field="bid_asc" currentField={sortBy} direction={sortDirection} />
</button>
</th>
<th className="text-right px-4 sm:px-6 py-4 hidden sm:table-cell">
<button
onClick={() => handleSort('bids')}
title="Number of bids placed"
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
>
Bids
<SortIcon field="bids" currentField={sortBy} direction={sortDirection} />
</button>
</th>
<th className="text-right px-4 sm:px-6 py-4 hidden md:table-cell">
<span className="text-ui-sm text-foreground-subtle font-medium" title="Time remaining">Time Left</span>
</th>
{activeTab === 'opportunities' && (
<th className="text-center px-4 sm:px-6 py-4">
<span className="text-ui-sm text-foreground-subtle font-medium" title="Opportunity score">Score</span>
</th>
)}
<th className="px-4 sm:px-6 py-4"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{sortedAuctions.length === 0 ? (
<tr>
<td colSpan={activeTab === 'opportunities' ? 7 : 6} className="px-6 py-12 text-center text-foreground-muted">
{activeTab === 'opportunities'
? 'No opportunities right now — check back later!'
: searchQuery
? `No auctions found matching "${searchQuery}"`
: 'No auctions found'}
</td>
</tr>
) : (
sortedAuctions.map((auction, idx) => {
const oppData = getOpportunityData(auction.domain)
return (
<tr
key={`${auction.domain}-${idx}`}
className="hover:bg-background-secondary/50 transition-colors group"
>
{/* Domain */}
<td className="px-4 sm:px-6 py-4">
<div className="flex flex-col gap-1">
<a
href={auction.affiliate_url}
target="_blank"
rel="noopener noreferrer"
title={`Go to ${auction.platform} to bid on ${auction.domain}`}
className="font-mono text-body-sm sm:text-body font-medium text-foreground hover:text-accent transition-colors"
>
{auction.domain}
</a>
<div className="flex items-center gap-2 text-body-xs text-foreground-subtle lg:hidden">
<span className={clsx("text-ui-xs px-1.5 py-0.5 rounded", getPlatformBadgeClass(auction.platform))}>
{auction.platform}
</span>
{auction.age_years && (
<span title={`Domain age: ${auction.age_years} years`}>{auction.age_years}y</span>
)}
</div>
</div>
</td>
{/* Platform */}
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell">
<div className="flex flex-col gap-1">
<span
className={clsx("text-ui-sm px-2 py-0.5 rounded-full w-fit", getPlatformBadgeClass(auction.platform))}
title={`${auction.platform} - Click Bid to go to auction`}
>
{auction.platform}
</span>
{auction.age_years && (
<span className="text-body-xs text-foreground-subtle" title={`Domain age: ${auction.age_years} years`}>
<Clock className="w-3 h-3 inline mr-1" />
{auction.age_years}y
</span>
)}
</div>
</td>
{/* Current Bid */}
<td className="px-4 sm:px-6 py-4 text-right">
<span
className="text-body-sm font-medium text-foreground"
title={`Current highest bid: ${formatCurrency(auction.current_bid)}`}
>
{formatCurrency(auction.current_bid)}
</span>
{auction.buy_now_price && (
<p className="text-ui-xs text-accent" title={`Buy Now for ${formatCurrency(auction.buy_now_price)}`}>
Buy: {formatCurrency(auction.buy_now_price)}
</p>
)}
</td>
{/* Bids */}
<td className="px-4 sm:px-6 py-4 text-right hidden sm:table-cell">
<span
className={clsx(
"text-body-sm font-medium inline-flex items-center gap-1",
auction.num_bids >= 20 ? "text-accent" :
auction.num_bids >= 10 ? "text-warning" :
"text-foreground-muted"
)}
title={`${auction.num_bids} bids - ${auction.num_bids >= 20 ? 'High competition!' : auction.num_bids >= 10 ? 'Moderate interest' : 'Low competition'}`}
>
{auction.num_bids}
{auction.num_bids >= 20 && <Flame className="w-3 h-3" />}
</span>
</td>
{/* Time Left */}
<td className="px-4 sm:px-6 py-4 text-right hidden md:table-cell">
<span
className={clsx("text-body-sm font-medium", getTimeColor(auction.time_remaining))}
title={`Auction ends: ${new Date(auction.end_time).toLocaleString()}`}
>
{auction.time_remaining}
</span>
</td>
{/* Score (opportunities only) */}
{activeTab === 'opportunities' && oppData && (
<td className="px-4 sm:px-6 py-4 text-center">
<span
className="inline-flex items-center justify-center w-9 h-9 bg-accent/10 text-accent font-bold rounded-lg text-body-sm"
title={`Score: ${oppData.opportunity_score}${oppData.reasoning ? ' - ' + oppData.reasoning : ''}`}
>
{oppData.opportunity_score}
</span>
</td>
)}
{/* Action */}
<td className="px-4 sm:px-6 py-4 text-right">
<a
href={auction.affiliate_url}
target="_blank"
rel="noopener noreferrer"
title={`Open ${auction.platform} to place your bid`}
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background text-ui-sm font-medium rounded-lg
hover:bg-foreground/90 transition-all opacity-70 group-hover:opacity-100"
>
Bid
<ExternalLink className="w-3.5 h-3.5" />
</a>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
)}
{/* Info Footer */}
<div className="mt-10 p-5 bg-background-secondary/30 border border-border rounded-xl animate-slide-up">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-foreground/5 rounded-xl flex items-center justify-center shrink-0">
<Info className="w-5 h-5 text-foreground-muted" />
</div>
<div>
<h4 className="text-body font-medium text-foreground mb-1.5">
{TAB_DESCRIPTIONS[activeTab].title}
</h4>
<p className="text-body-sm text-foreground-subtle leading-relaxed mb-3">
{TAB_DESCRIPTIONS[activeTab].description}
</p>
<p className="text-body-sm text-foreground-subtle leading-relaxed">
<span className="text-foreground-muted font-medium">Sources:</span> GoDaddy, Sedo, NameJet, DropCatch, ExpiredDomains.
Click "Bid" to go to the auction we don't handle transactions.
</p>
</div>
</div>
</div>
</div>
</main>
<Footer />
</div>
)
}