yves.gugger 38a5ebd8a4 feat: Transparent Portfolio Valuation & Smart Pounce Auctions
PORTFOLIO VALUATION (100% Transparent):
- Completely rewritten valuation algorithm with clear formula
- Shows exact calculation: Base × Length × TLD × Keyword × Brand
- Each factor explained with reason (e.g., '4-letter domain ×5.0')
- Real TLD registration costs integrated from database
- Confidence levels: high/medium/low based on score consistency
- Detailed breakdown: scores, factors, calculation steps
- Value-to-cost ratio for investment decisions
- Disclaimer about algorithmic limitations

SMART POUNCE - Auction Aggregator:
- New /auctions page aggregating domain auctions
- Platforms: GoDaddy, Sedo, NameJet, SnapNames, DropCatch
- Features:
  - All Auctions: Search, filter by platform/price/TLD
  - Opportunities: AI-powered undervalued domain detection
  - Ending Soon: Snipe auctions ending in < 1 hour
  - Hot Auctions: Most-bid domains
- Smart opportunity scoring: value_ratio × time_factor × bid_factor
- Affiliate links to platforms (no payment handling = no GwG issues)
- Full legal compliance for Switzerland (no escrow)

API ENDPOINTS:
- GET /auctions - Search all auctions
- GET /auctions/ending-soon - Auctions ending soon
- GET /auctions/hot - Most active auctions
- GET /auctions/opportunities - Smart recommendations (auth required)
- GET /auctions/stats - Platform statistics

UI UPDATES:
- New 'Auctions' link in navigation (desktop + mobile)
- Auction cards with bid info, time remaining, platform badges
- Opportunity analysis with profit potential
- Color-coded time urgency (red < 1h, yellow < 2h)
2025-12-08 13:39:01 +01:00

614 lines
26 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 {
Zap,
Clock,
TrendingUp,
ExternalLink,
Filter,
Search,
Flame,
Timer,
DollarSign,
Users,
ArrowUpRight,
ChevronRight,
Lock,
BarChart3,
Target,
Sparkles,
} 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: {
estimated_value: number
current_bid: number
value_ratio: number
potential_profit: number
opportunity_score: number
recommendation: string
}
}
const PLATFORMS = ['All', 'GoDaddy', 'Sedo', 'NameJet', 'SnapNames', 'DropCatch']
export default function AuctionsPage() {
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
const [auctions, setAuctions] = useState<Auction[]>([])
const [opportunities, setOpportunities] = useState<Opportunity[]>([])
const [hotAuctions, setHotAuctions] = useState<Auction[]>([])
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'all' | 'opportunities' | 'ending'>('all')
// Filters
const [searchQuery, setSearchQuery] = useState('')
const [selectedPlatform, setSelectedPlatform] = useState('All')
const [maxBid, setMaxBid] = useState<string>('')
useEffect(() => {
checkAuth()
loadData()
}, [checkAuth])
const loadData = async () => {
setLoading(true)
try {
const [auctionsData, hotData, endingData] = await Promise.all([
api.getAuctions(),
api.getHotAuctions(),
api.getEndingSoonAuctions(),
])
setAuctions(auctionsData.auctions || [])
setHotAuctions(hotData || [])
setEndingSoon(endingData || [])
// Load opportunities only for authenticated users
if (isAuthenticated) {
try {
const oppData = await api.getAuctionOpportunities()
setOpportunities(oppData.opportunities || [])
} catch (e) {
console.error('Failed to load opportunities:', e)
}
}
} catch (error) {
console.error('Failed to load auction data:', error)
} finally {
setLoading(false)
}
}
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value)
}
const filteredAuctions = auctions.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 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 getPlatformColor = (platform: string) => {
switch (platform) {
case 'GoDaddy':
return 'bg-blue-500/10 text-blue-400'
case 'Sedo':
return 'bg-green-500/10 text-green-400'
case 'NameJet':
return 'bg-purple-500/10 text-purple-400'
case 'SnapNames':
return 'bg-orange-500/10 text-orange-400'
case 'DropCatch':
return 'bg-pink-500/10 text-pink-400'
default:
return 'bg-background-tertiary text-foreground-muted'
}
}
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<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 flex flex-col">
{/* Ambient glow */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
</div>
<Header />
<main className="relative pt-28 sm:pt-32 pb-16 sm:pb-20 px-4 sm:px-6 flex-1">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="text-center mb-12 sm:mb-16 animate-fade-in">
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-accent-muted border border-accent/20 rounded-full mb-6">
<Zap className="w-4 h-4 text-accent" />
<span className="text-ui-sm text-accent font-medium">Smart Pounce</span>
</div>
<h1 className="font-display text-[2rem] sm:text-[2.75rem] md:text-[3.5rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4">
Domain Auctions
</h1>
<p className="text-body sm:text-body-lg text-foreground-muted max-w-2xl mx-auto">
Aggregated auctions from GoDaddy, Sedo, NameJet & more.
Find undervalued domains before anyone else.
</p>
</div>
{/* Strategy Banner */}
<div className="mb-8 p-5 bg-gradient-to-r from-accent/10 to-accent/5 border border-accent/20 rounded-xl">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-accent/20 rounded-xl flex items-center justify-center shrink-0">
<Target className="w-5 h-5 text-accent" />
</div>
<div>
<h3 className="text-body font-medium text-foreground mb-1">Smart Pounce Strategy</h3>
<p className="text-body-sm text-foreground-muted">
We aggregate auctions from multiple platforms so you can find the best deals.
We don't handle payments — click through to the platform to bid.
Pro tip: Focus on auctions ending soon with low bid counts.
</p>
</div>
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-8">
<div className="p-4 bg-background-secondary/50 border border-border rounded-xl">
<div className="flex items-center gap-2 text-foreground-subtle mb-2">
<BarChart3 className="w-4 h-4" />
<span className="text-ui-sm">Active Auctions</span>
</div>
<p className="text-heading-sm font-medium text-foreground">{auctions.length}</p>
</div>
<div className="p-4 bg-background-secondary/50 border border-border rounded-xl">
<div className="flex items-center gap-2 text-foreground-subtle mb-2">
<Timer className="w-4 h-4" />
<span className="text-ui-sm">Ending Soon</span>
</div>
<p className="text-heading-sm font-medium text-warning">{endingSoon.length}</p>
</div>
<div className="p-4 bg-background-secondary/50 border border-border rounded-xl">
<div className="flex items-center gap-2 text-foreground-subtle mb-2">
<Flame className="w-4 h-4" />
<span className="text-ui-sm">Hot Auctions</span>
</div>
<p className="text-heading-sm font-medium text-accent">{hotAuctions.length}</p>
</div>
<div className="p-4 bg-background-secondary/50 border border-border rounded-xl">
<div className="flex items-center gap-2 text-foreground-subtle mb-2">
<Sparkles className="w-4 h-4" />
<span className="text-ui-sm">Opportunities</span>
</div>
<p className="text-heading-sm font-medium text-accent">
{isAuthenticated ? opportunities.length : ''}
</p>
</div>
</div>
{/* Tabs */}
<div className="flex items-center gap-1 p-1 bg-background-secondary border border-border rounded-xl w-fit mb-6">
<button
onClick={() => setActiveTab('all')}
className={clsx(
"flex items-center gap-2 px-4 py-2 text-ui-sm font-medium rounded-lg transition-all",
activeTab === 'all'
? "bg-foreground text-background"
: "text-foreground-muted hover:text-foreground"
)}
>
<BarChart3 className="w-4 h-4" />
All Auctions
</button>
<button
onClick={() => setActiveTab('opportunities')}
className={clsx(
"flex items-center gap-2 px-4 py-2 text-ui-sm font-medium rounded-lg transition-all",
activeTab === 'opportunities'
? "bg-foreground text-background"
: "text-foreground-muted hover:text-foreground"
)}
>
<Sparkles className="w-4 h-4" />
Opportunities
{!isAuthenticated && <Lock className="w-3 h-3" />}
</button>
<button
onClick={() => setActiveTab('ending')}
className={clsx(
"flex items-center gap-2 px-4 py-2 text-ui-sm font-medium rounded-lg transition-all",
activeTab === 'ending'
? "bg-foreground text-background"
: "text-foreground-muted hover:text-foreground"
)}
>
<Timer className="w-4 h-4" />
Ending Soon
</button>
</div>
{/* Filters (for All tab) */}
{activeTab === 'all' && (
<div className="flex flex-wrap gap-3 mb-6">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
<input
type="text"
placeholder="Search domains..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-11 pr-4 py-2.5 bg-background-secondary border border-border rounded-xl
text-body-sm text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:border-border-hover transition-all"
/>
</div>
<select
value={selectedPlatform}
onChange={(e) => setSelectedPlatform(e.target.value)}
className="px-4 py-2.5 bg-background-secondary border border-border rounded-xl
text-body-sm text-foreground focus:outline-none focus:border-border-hover"
>
{PLATFORMS.map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
<input
type="number"
placeholder="Max bid..."
value={maxBid}
onChange={(e) => setMaxBid(e.target.value)}
className="w-32 px-4 py-2.5 bg-background-secondary border border-border rounded-xl
text-body-sm text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:border-border-hover"
/>
</div>
)}
{/* Content */}
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : activeTab === 'all' ? (
/* All Auctions Grid */
<div className="grid gap-4">
{filteredAuctions.length === 0 ? (
<div className="text-center py-12 text-foreground-muted">
No auctions match your filters
</div>
) : (
filteredAuctions.map((auction, idx) => (
<div
key={`${auction.domain}-${idx}`}
className="p-5 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover transition-all group"
>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="font-mono text-body-lg font-medium text-foreground">
{auction.domain}
</span>
<span className={clsx(
"text-ui-xs px-2 py-0.5 rounded-full",
getPlatformColor(auction.platform)
)}>
{auction.platform}
</span>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-body-sm text-foreground-muted">
<span className="flex items-center gap-1.5">
<Users className="w-3.5 h-3.5" />
{auction.num_bids} bids
</span>
{auction.age_years && (
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{auction.age_years} years old
</span>
)}
{auction.traffic && (
<span className="flex items-center gap-1.5">
<TrendingUp className="w-3.5 h-3.5" />
{auction.traffic.toLocaleString()} visits/mo
</span>
)}
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-right">
<p className="text-ui-xs text-foreground-muted mb-1">Current Bid</p>
<p className="text-body-lg font-medium text-foreground">
{formatCurrency(auction.current_bid)}
</p>
</div>
<div className="text-right">
<p className="text-ui-xs text-foreground-muted mb-1">Time Left</p>
<p className={clsx(
"text-body-lg font-medium flex items-center gap-1.5",
getTimeColor(auction.time_remaining)
)}>
<Timer className="w-4 h-4" />
{auction.time_remaining}
</p>
</div>
<a
href={auction.affiliate_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-background text-ui-sm font-medium rounded-xl
hover:bg-accent-hover transition-all"
>
Bid Now
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
{auction.buy_now_price && (
<div className="mt-3 pt-3 border-t border-border flex items-center justify-between">
<span className="text-body-sm text-foreground-subtle">
Buy Now: {formatCurrency(auction.buy_now_price)}
</span>
{auction.reserve_met !== null && (
<span className={clsx(
"text-ui-xs px-2 py-0.5 rounded-full",
auction.reserve_met
? "bg-accent-muted text-accent"
: "bg-warning-muted text-warning"
)}>
{auction.reserve_met ? 'Reserve Met' : 'Reserve Not Met'}
</span>
)}
</div>
)}
</div>
))
)}
</div>
) : activeTab === 'opportunities' ? (
/* Smart Opportunities */
!isAuthenticated ? (
<div className="text-center py-16 border border-dashed border-border rounded-xl bg-background-secondary/30">
<div className="w-12 h-12 bg-background-tertiary rounded-xl flex items-center justify-center mx-auto mb-4">
<Lock className="w-6 h-6 text-foreground-subtle" />
</div>
<p className="text-body text-foreground-muted mb-2">Sign in to see opportunities</p>
<p className="text-body-sm text-foreground-subtle mb-6">
Our algorithm finds undervalued domains based on your watchlist and market data.
</p>
<Link
href="/register"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-accent text-background text-ui font-medium rounded-xl
hover:bg-accent-hover transition-all"
>
Get Started Free
<ArrowUpRight className="w-4 h-4" />
</Link>
</div>
) : opportunities.length === 0 ? (
<div className="text-center py-12 text-foreground-muted">
No opportunities found right now. Check back later!
</div>
) : (
<div className="grid gap-4">
{opportunities.map((opp, idx) => (
<div
key={`${opp.auction.domain}-${idx}`}
className="p-5 bg-background-secondary/50 border border-accent/20 rounded-xl"
>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="font-mono text-body-lg font-medium text-foreground">
{opp.auction.domain}
</span>
<span className={clsx(
"text-ui-xs px-2 py-0.5 rounded-full",
opp.analysis.recommendation === 'Strong buy'
? "bg-accent-muted text-accent"
: opp.analysis.recommendation === 'Consider'
? "bg-warning-muted text-warning"
: "bg-background-tertiary text-foreground-muted"
)}>
{opp.analysis.recommendation}
</span>
</div>
{/* Analysis Breakdown */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-3">
<div className="p-2 bg-background-tertiary rounded-lg">
<p className="text-ui-xs text-foreground-subtle">Current Bid</p>
<p className="text-body-sm font-medium text-foreground">
{formatCurrency(opp.analysis.current_bid)}
</p>
</div>
<div className="p-2 bg-background-tertiary rounded-lg">
<p className="text-ui-xs text-foreground-subtle">Est. Value</p>
<p className="text-body-sm font-medium text-accent">
{formatCurrency(opp.analysis.estimated_value)}
</p>
</div>
<div className="p-2 bg-background-tertiary rounded-lg">
<p className="text-ui-xs text-foreground-subtle">Value Ratio</p>
<p className="text-body-sm font-medium text-foreground">
{opp.analysis.value_ratio}×
</p>
</div>
<div className="p-2 bg-background-tertiary rounded-lg">
<p className="text-ui-xs text-foreground-subtle">Potential Profit</p>
<p className={clsx(
"text-body-sm font-medium",
opp.analysis.potential_profit > 0 ? "text-accent" : "text-danger"
)}>
{opp.analysis.potential_profit > 0 ? '+' : ''}
{formatCurrency(opp.analysis.potential_profit)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-ui-xs text-foreground-muted mb-1">Time Left</p>
<p className={clsx(
"text-body font-medium",
getTimeColor(opp.auction.time_remaining)
)}>
{opp.auction.time_remaining}
</p>
</div>
<a
href={opp.auction.affiliate_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-background text-ui-sm font-medium rounded-xl
hover:bg-accent-hover transition-all"
>
Bid Now
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
</div>
))}
</div>
)
) : (
/* Ending Soon */
<div className="grid gap-4">
{endingSoon.length === 0 ? (
<div className="text-center py-12 text-foreground-muted">
No auctions ending soon
</div>
) : (
endingSoon.map((auction, idx) => (
<div
key={`${auction.domain}-${idx}`}
className="p-5 bg-background-secondary/50 border border-warning/20 rounded-xl"
>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-warning-muted rounded-xl flex items-center justify-center">
<Timer className="w-5 h-5 text-warning" />
</div>
<div>
<span className="font-mono text-body-lg font-medium text-foreground block">
{auction.domain}
</span>
<span className="text-body-sm text-foreground-muted">
{auction.num_bids} bids on {auction.platform}
</span>
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-right">
<p className="text-heading-sm font-medium text-foreground">
{formatCurrency(auction.current_bid)}
</p>
</div>
<div className="text-right">
<p className={clsx(
"text-body-lg font-bold",
getTimeColor(auction.time_remaining)
)}>
{auction.time_remaining}
</p>
</div>
<a
href={auction.affiliate_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2.5 bg-warning text-background text-ui-sm font-medium rounded-xl
hover:bg-warning/90 transition-all"
>
Snipe Now
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
</div>
))
)}
</div>
)}
{/* Disclaimer */}
<div className="mt-12 p-5 bg-background-secondary/30 border border-border rounded-xl">
<h4 className="text-body-sm font-medium text-foreground mb-2">How Smart Pounce Works</h4>
<p className="text-body-sm text-foreground-subtle">
We aggregate domain auctions from multiple platforms (GoDaddy, Sedo, NameJet, etc.)
and display them in one place. When you click "Bid Now", you're taken directly to
the auction platform we don't handle any payments or domain transfers.
This keeps things simple and compliant with Swiss regulations.
</p>
</div>
</div>
</main>
<Footer />
</div>
)
}