feat: Portfolio Sell Wizard & Tier-based Limits
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
- Add 3-step sell wizard modal in Portfolio (Details → DNS Verify → Done) - Implement DNS TXT verification for domain ownership - Remove Marketplace button from For Sale page - Show clear tier-based limits everywhere: - Watchlist: Scout=5, Trader=50, Tycoon=500 - Listings: Scout=0, Trader=5, Tycoon=50 - Add plan comparison in For Sale upgrade section - Prevent selling if listing limit reached - Add copy-to-clipboard for DNS records
This commit is contained in:
@ -1,721 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { PlatformBadge } from '@/components/PremiumTable'
|
||||
import {
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Search,
|
||||
Flame,
|
||||
Timer,
|
||||
Gavel,
|
||||
DollarSign,
|
||||
X,
|
||||
Lock,
|
||||
TrendingUp,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Sparkles,
|
||||
Diamond,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface MarketItem {
|
||||
id: string
|
||||
domain: string
|
||||
tld: string
|
||||
price: number
|
||||
currency: string
|
||||
price_type: 'bid' | 'fixed' | 'negotiable'
|
||||
status: 'auction' | 'instant'
|
||||
source: string
|
||||
is_pounce: boolean
|
||||
verified: boolean
|
||||
time_remaining?: string
|
||||
end_time?: string
|
||||
num_bids?: number
|
||||
slug?: string
|
||||
seller_verified: boolean
|
||||
url: string
|
||||
is_external: boolean
|
||||
pounce_score: number
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type TabType = 'all' | 'ending' | 'hot'
|
||||
type SortField = 'domain' | 'ending' | 'bid' | 'bids'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'All', name: 'All Sources' },
|
||||
{ id: 'GoDaddy', name: 'GoDaddy' },
|
||||
{ id: 'Sedo', name: 'Sedo' },
|
||||
{ id: 'NameJet', name: 'NameJet' },
|
||||
{ id: 'DropCatch', name: 'DropCatch' },
|
||||
]
|
||||
|
||||
// Premium TLDs that look professional (from analysis_1.md)
|
||||
const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'de', 'ch', 'net', 'org', 'app', 'dev', 'xyz']
|
||||
|
||||
// Vanity Filter: Only show "beautiful" domains to non-authenticated users (from analysis_1.md)
|
||||
// Rules: No numbers (except short domains), no hyphens, length < 12, only premium TLDs
|
||||
function isVanityDomain(auction: Auction): boolean {
|
||||
const domain = auction.domain
|
||||
const parts = domain.split('.')
|
||||
if (parts.length < 2) return false
|
||||
|
||||
const name = parts[0]
|
||||
const tld = parts.slice(1).join('.').toLowerCase()
|
||||
|
||||
// Check TLD is premium
|
||||
if (!PREMIUM_TLDS.includes(tld)) return false
|
||||
|
||||
// Check length (max 12 characters for the name)
|
||||
if (name.length > 12) return false
|
||||
|
||||
// No hyphens
|
||||
if (name.includes('-')) return false
|
||||
|
||||
// No numbers (unless domain is 4 chars or less - short domains are valuable)
|
||||
if (name.length > 4 && /\d/.test(name)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Generate a mock "Deal Score" for display purposes
|
||||
// In production, this would come from a valuation API
|
||||
function getDealScore(auction: Auction): number | null {
|
||||
// Simple heuristic based on domain characteristics
|
||||
let score = 50
|
||||
|
||||
// Short domains are more valuable
|
||||
const name = auction.domain.split('.')[0]
|
||||
if (name.length <= 4) score += 20
|
||||
else if (name.length <= 6) score += 10
|
||||
|
||||
// Premium TLDs
|
||||
if (['com', 'io', 'ai'].includes(auction.tld)) score += 15
|
||||
|
||||
// Age bonus
|
||||
if (auction.age_years && auction.age_years > 5) score += 10
|
||||
|
||||
// High competition = good domain
|
||||
if (auction.num_bids >= 20) score += 15
|
||||
else if (auction.num_bids >= 10) score += 10
|
||||
|
||||
// Cap at 100
|
||||
return Math.min(score, 100)
|
||||
}
|
||||
|
||||
function SortIcon({ field, currentField, direction }: { field: SortField, currentField: SortField, direction: SortDirection }) {
|
||||
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" />
|
||||
}
|
||||
|
||||
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 [pounceItems, setPounceItems] = useState<MarketItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all')
|
||||
const [sortField, setSortField] = useState<SortField>('ending')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedPlatform, setSelectedPlatform] = useState('All')
|
||||
const [maxBid, setMaxBid] = useState('')
|
||||
/**
|
||||
* Redirect /auctions to /market
|
||||
* This page is kept for backwards compatibility
|
||||
*/
|
||||
export default function AuctionsRedirect() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
loadAuctions()
|
||||
}, [checkAuth])
|
||||
router.replace('/market')
|
||||
}, [router])
|
||||
|
||||
const loadAuctions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Use unified feed API for all data - same as Terminal Market Page
|
||||
const [allFeed, endingFeed, hotFeed, pounceFeed] = await Promise.all([
|
||||
api.getMarketFeed({ source: 'all', limit: 100, sortBy: 'time' }),
|
||||
api.getMarketFeed({ source: 'external', endingWithin: 24, limit: 50, sortBy: 'time' }),
|
||||
api.getMarketFeed({ source: 'external', limit: 50, sortBy: 'score' }), // Hot = highest score
|
||||
api.getMarketFeed({ source: 'pounce', limit: 10 }),
|
||||
])
|
||||
|
||||
// Convert MarketItem to Auction format for compatibility
|
||||
const convertToAuction = (item: MarketItem): Auction => ({
|
||||
domain: item.domain,
|
||||
platform: item.source,
|
||||
platform_url: item.url,
|
||||
current_bid: item.price,
|
||||
currency: item.currency,
|
||||
num_bids: item.num_bids || 0,
|
||||
end_time: item.end_time || '',
|
||||
time_remaining: item.time_remaining || '',
|
||||
buy_now_price: item.price_type === 'fixed' ? item.price : null,
|
||||
reserve_met: null,
|
||||
traffic: null,
|
||||
age_years: null,
|
||||
tld: item.tld,
|
||||
affiliate_url: item.url,
|
||||
})
|
||||
|
||||
// Filter out Pounce Direct from auction lists (they go in separate section)
|
||||
const externalOnly = (items: MarketItem[]) => items.filter(i => !i.is_pounce).map(convertToAuction)
|
||||
|
||||
setAllAuctions(externalOnly(allFeed.items || []))
|
||||
setEndingSoon(externalOnly(endingFeed.items || []))
|
||||
setHotAuctions(externalOnly(hotFeed.items || []))
|
||||
setPounceItems(pounceFeed.items || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load auctions:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentAuctions = (): Auction[] => {
|
||||
switch (activeTab) {
|
||||
case 'ending': return endingSoon
|
||||
case 'hot': return hotAuctions
|
||||
default: return allAuctions
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Vanity Filter for non-authenticated users (from analysis_1.md)
|
||||
// Shows only "beautiful" domains to visitors - no spam/trash
|
||||
const displayAuctions = useMemo(() => {
|
||||
const current = getCurrentAuctions()
|
||||
if (isAuthenticated) {
|
||||
// Authenticated users see all auctions
|
||||
return current
|
||||
}
|
||||
// Non-authenticated users only see "vanity" domains (clean, professional-looking)
|
||||
return current.filter(isVanityDomain)
|
||||
}, [activeTab, allAuctions, endingSoon, hotAuctions, isAuthenticated])
|
||||
|
||||
const filteredAuctions = displayAuctions.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 handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedAuctions = [...filteredAuctions].sort((a, b) => {
|
||||
const modifier = sortDirection === 'asc' ? 1 : -1
|
||||
switch (sortField) {
|
||||
case 'domain':
|
||||
return a.domain.localeCompare(b.domain) * modifier
|
||||
case 'bid':
|
||||
return (a.current_bid - b.current_bid) * modifier
|
||||
case 'bids':
|
||||
return (a.num_bids - b.num_bids) * modifier
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
const formatCurrency = (amount: number, currency = 'USD') => {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
|
||||
}
|
||||
|
||||
const getTimeColor = (timeRemaining: string) => {
|
||||
if (timeRemaining.includes('m') && !timeRemaining.includes('h')) return 'text-red-400'
|
||||
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 12) return 'text-amber-400'
|
||||
return 'text-foreground-muted'
|
||||
}
|
||||
|
||||
// Hot auctions preview for the hero section
|
||||
const hotPreview = hotAuctions.slice(0, 4)
|
||||
|
||||
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 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">
|
||||
{/* Hero Header - centered like TLD pricing */}
|
||||
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Live Market</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">
|
||||
{/* Use "Live Feed" or "Curated Opportunities" if count is small (from report.md) */}
|
||||
{allAuctions.length >= 50
|
||||
? `${allAuctions.length}+ Live Auctions`
|
||||
: 'Live Auction Feed'}
|
||||
</h1>
|
||||
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
||||
{isAuthenticated
|
||||
? 'All auctions from GoDaddy, Sedo, NameJet & DropCatch. Unfiltered.'
|
||||
: 'Curated opportunities from GoDaddy, Sedo, NameJet & DropCatch.'}
|
||||
</p>
|
||||
{!isAuthenticated && displayAuctions.length < allAuctions.length && (
|
||||
<p className="mt-2 text-sm text-accent flex items-center justify-center gap-1">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Showing {displayAuctions.length} premium domains • Sign in to see all {allAuctions.length}
|
||||
</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">
|
||||
<Lock 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 AI-powered analysis and personalized recommendations.
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Pounce Direct Section - Featured */}
|
||||
{pounceItems.length > 0 && (
|
||||
<div className="mb-12 sm:mb-16 animate-slide-up">
|
||||
<div className="flex items-center gap-3 mb-4 sm:mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Diamond className="w-5 h-5 text-accent fill-accent/20" />
|
||||
<h2 className="text-body-lg sm:text-heading-sm font-medium text-foreground">
|
||||
Pounce Exclusive
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-ui-sm text-foreground-subtle">Verified • Instant Buy • 0% Commission</span>
|
||||
</div>
|
||||
<div className="border border-accent/20 rounded-xl overflow-hidden bg-gradient-to-br from-accent/5 to-transparent">
|
||||
{pounceItems.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.url}
|
||||
className="flex items-center justify-between px-5 py-4 border-b border-accent/10 last:border-b-0 hover:bg-accent/5 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Diamond className="w-5 h-5 text-accent fill-accent/20 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-mono text-body font-medium text-foreground group-hover:text-accent transition-colors">
|
||||
{item.domain}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{item.verified && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-accent bg-accent/10 px-2 py-0.5 rounded-full">
|
||||
<ShieldCheck className="w-3 h-3" />
|
||||
Verified
|
||||
</span>
|
||||
)}
|
||||
<span className="text-ui-sm text-foreground-subtle">
|
||||
Score: {item.pounce_score}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-body-lg font-medium text-foreground">
|
||||
{formatCurrency(item.price, item.currency)}
|
||||
</div>
|
||||
<div className="text-ui-sm text-accent">Instant Buy</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-accent text-background rounded-lg text-ui-sm font-bold opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Buy Now
|
||||
<Zap className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 text-center">
|
||||
<Link
|
||||
href="/buy"
|
||||
className="text-ui-sm text-accent hover:underline"
|
||||
>
|
||||
Browse all Pounce listings →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hot Auctions Preview */}
|
||||
{hotPreview.length > 0 && (
|
||||
<div className="mb-12 sm:mb-16 animate-slide-up">
|
||||
<h2 className="text-body-lg sm:text-heading-sm font-medium text-foreground mb-4 sm:mb-6 flex items-center gap-2">
|
||||
<Flame className="w-5 h-5 text-accent" />
|
||||
Hot Right Now
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
{hotPreview.map((auction) => (
|
||||
<a
|
||||
key={`${auction.domain}-${auction.platform}`}
|
||||
href={auction.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-4 sm:p-5 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover hover:bg-background-secondary transition-all duration-300 text-left group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-mono text-body-lg sm:text-heading-sm text-foreground group-hover:text-accent transition-colors">
|
||||
{auction.domain}
|
||||
</span>
|
||||
<span className="text-ui-sm font-medium px-2 py-0.5 rounded-full text-accent bg-accent-muted flex items-center gap-1">
|
||||
<Flame className="w-3 h-3" />
|
||||
{auction.num_bids}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-body-sm text-foreground-muted">
|
||||
{formatCurrency(auction.current_bid)}
|
||||
</span>
|
||||
<span className={clsx("text-body-sm", getTimeColor(auction.time_remaining))}>
|
||||
{auction.time_remaining}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<PlatformBadge platform={auction.platform} />
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</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)}
|
||||
className="px-4 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||
text-body text-foreground cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
>
|
||||
{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-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max bid"
|
||||
value={maxBid}
|
||||
onChange={(e) => setMaxBid(e.target.value)}
|
||||
className="w-32 pl-10 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap gap-2 mb-6 animate-slide-up">
|
||||
{[
|
||||
{ id: 'all' as const, label: 'All Auctions', icon: Gavel, count: allAuctions.length },
|
||||
{ id: 'ending' as const, label: 'Ending Soon', icon: Timer, count: endingSoon.length },
|
||||
{ id: 'hot' as const, label: 'Hot', icon: Flame, count: hotAuctions.length },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-xl transition-all",
|
||||
activeTab === tab.id
|
||||
? "bg-accent text-background"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary border border-border"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
<span className={clsx(
|
||||
"text-xs px-1.5 py-0.5 rounded",
|
||||
activeTab === tab.id ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{tab.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Auctions Table */}
|
||||
<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('domain')}
|
||||
className="flex items-center gap-2 text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Domain
|
||||
<SortIcon field="domain" currentField={sortField} 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')}
|
||||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Current Bid
|
||||
<SortIcon field="bid" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-center px-4 sm:px-6 py-4 hidden md:table-cell">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium flex items-center justify-center gap-1">
|
||||
Deal Score
|
||||
{!isAuthenticated && <Lock className="w-3 h-3" />}
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-4 hidden sm:table-cell">
|
||||
<button
|
||||
onClick={() => handleSort('bids')}
|
||||
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={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-4 hidden md:table-cell">
|
||||
<button
|
||||
onClick={() => handleSort('ending')}
|
||||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Time Left
|
||||
<SortIcon field="ending" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading ? (
|
||||
// Loading skeleton
|
||||
Array.from({ length: 10 }).map((_, idx) => (
|
||||
<tr key={idx} className="animate-pulse">
|
||||
<td className="px-4 sm:px-6 py-4"><div className="h-4 w-32 bg-background-tertiary rounded" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell"><div className="h-4 w-20 bg-background-tertiary rounded" /></td>
|
||||
<td className="px-4 sm:px-6 py-4"><div className="h-4 w-16 bg-background-tertiary rounded ml-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden md:table-cell"><div className="h-8 w-8 bg-background-tertiary rounded mx-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden sm:table-cell"><div className="h-4 w-12 bg-background-tertiary rounded ml-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden md:table-cell"><div className="h-4 w-16 bg-background-tertiary rounded ml-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4"><div className="h-8 w-16 bg-background-tertiary rounded ml-auto" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : sortedAuctions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-foreground-muted">
|
||||
{searchQuery ? `No auctions found matching "${searchQuery}"` : 'No auctions found'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedAuctions.map((auction) => (
|
||||
<tr
|
||||
key={`${auction.domain}-${auction.platform}`}
|
||||
className="hover:bg-background-secondary/50 transition-colors group"
|
||||
>
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<div>
|
||||
<a
|
||||
href={auction.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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 mt-1 lg:hidden">
|
||||
<PlatformBadge platform={auction.platform} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell">
|
||||
<div className="space-y-1">
|
||||
<PlatformBadge platform={auction.platform} />
|
||||
{auction.age_years && (
|
||||
<span className="text-ui-sm text-foreground-subtle flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> {auction.age_years}y
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<div>
|
||||
<span className="text-body-sm font-medium text-foreground">
|
||||
{formatCurrency(auction.current_bid)}
|
||||
</span>
|
||||
{auction.buy_now_price && (
|
||||
<p className="text-ui-sm text-accent">Buy: {formatCurrency(auction.buy_now_price)}</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
{/* Deal Score Column - locked for non-authenticated users */}
|
||||
<td className="px-4 sm:px-6 py-4 text-center hidden md:table-cell">
|
||||
{isAuthenticated ? (
|
||||
<div className="inline-flex flex-col items-center">
|
||||
<span className={clsx(
|
||||
"inline-flex items-center justify-center w-9 h-9 rounded-lg font-bold text-sm",
|
||||
(getDealScore(auction) ?? 0) >= 75 ? "bg-accent/20 text-accent" :
|
||||
(getDealScore(auction) ?? 0) >= 50 ? "bg-amber-500/20 text-amber-400" :
|
||||
"bg-foreground/10 text-foreground-muted"
|
||||
)}>
|
||||
{getDealScore(auction)}
|
||||
</span>
|
||||
{(getDealScore(auction) ?? 0) >= 75 && (
|
||||
<span className="text-[10px] text-accent mt-0.5 font-medium">Undervalued</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href="/login?redirect=/auctions"
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-lg bg-foreground/5 text-foreground-subtle
|
||||
hover:bg-accent/10 hover:text-accent transition-all group"
|
||||
title="Sign in to see Deal Score"
|
||||
>
|
||||
<Lock className="w-4 h-4 group-hover:scale-110 transition-transform" />
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 text-right hidden sm:table-cell">
|
||||
<span className={clsx(
|
||||
"font-medium flex items-center justify-end gap-1",
|
||||
auction.num_bids >= 20 ? "text-accent" : auction.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted"
|
||||
)}>
|
||||
{auction.num_bids}
|
||||
{auction.num_bids >= 20 && <Flame className="w-3 h-3" />}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 text-right hidden md:table-cell">
|
||||
<span className={clsx("font-medium", getTimeColor(auction.time_remaining))}>
|
||||
{auction.time_remaining}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<a
|
||||
href={auction.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-ui-sm text-accent hover:text-accent-hover transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
Bid
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{!loading && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<p className="text-ui-sm text-foreground-subtle">
|
||||
{searchQuery
|
||||
? `Found ${sortedAuctions.length} auctions matching "${searchQuery}"`
|
||||
: `${allAuctions.length} auctions available across ${PLATFORMS.length - 1} platforms`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-foreground-muted">Redirecting to Market...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
1187
frontend/src/app/intel/[tld]/page.tsx
Normal file
1187
frontend/src/app/intel/[tld]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
547
frontend/src/app/intel/page.tsx
Normal file
547
frontend/src/app/intel/page.tsx
Normal file
@ -0,0 +1,547 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { PremiumTable } from '@/components/PremiumTable'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
TrendingUp,
|
||||
ChevronRight,
|
||||
Search,
|
||||
X,
|
||||
Lock,
|
||||
Globe,
|
||||
AlertTriangle,
|
||||
ArrowUpDown,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface TldData {
|
||||
tld: string
|
||||
type: string
|
||||
description: string
|
||||
avg_registration_price: number
|
||||
min_registration_price: number
|
||||
max_registration_price: number
|
||||
min_renewal_price: number
|
||||
avg_renewal_price: number
|
||||
registrar_count: number
|
||||
trend: string
|
||||
price_change_7d: number
|
||||
price_change_1y: number
|
||||
price_change_3y: number
|
||||
risk_level: 'low' | 'medium' | 'high'
|
||||
risk_reason: string
|
||||
popularity_rank?: number
|
||||
}
|
||||
|
||||
interface TrendingTld {
|
||||
tld: string
|
||||
reason: string
|
||||
price_change: number
|
||||
current_price: number
|
||||
}
|
||||
|
||||
interface PaginationData {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
// TLDs that are shown completely to non-authenticated users (gemäß pounce_public.md)
|
||||
const PUBLIC_PREVIEW_TLDS = ['com', 'net', 'org']
|
||||
|
||||
// Sparkline component
|
||||
function Sparkline({ trend }: { trend: number }) {
|
||||
const isPositive = trend > 0
|
||||
const isNeutral = trend === 0
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<svg width="40" height="16" viewBox="0 0 40 16" className="overflow-visible">
|
||||
{isNeutral ? (
|
||||
<line x1="0" y1="8" x2="40" y2="8" stroke="currentColor" className="text-foreground-muted" strokeWidth="1.5" />
|
||||
) : isPositive ? (
|
||||
<polyline
|
||||
points="0,14 10,12 20,10 30,6 40,2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-orange-400"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
) : (
|
||||
<polyline
|
||||
points="0,2 10,6 20,10 30,12 40,14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-accent"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function IntelPage() {
|
||||
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
||||
const [tlds, setTlds] = useState<TldData[]>([])
|
||||
const [trending, setTrending] = useState<TrendingTld[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pagination, setPagination] = useState<PaginationData>({ total: 0, limit: 50, offset: 0, has_more: false })
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [sortBy, setSortBy] = useState('popularity')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(searchQuery)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
loadTrending()
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
loadTlds()
|
||||
}, [debouncedSearch, sortBy, page])
|
||||
|
||||
const loadTlds = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.getTldOverview(
|
||||
50,
|
||||
page * 50,
|
||||
sortBy as 'popularity' | 'price_asc' | 'price_desc' | 'name',
|
||||
debouncedSearch || undefined
|
||||
)
|
||||
|
||||
setTlds(data?.tlds || [])
|
||||
setPagination({
|
||||
total: data?.total || 0,
|
||||
limit: 50,
|
||||
offset: page * 50,
|
||||
has_more: data?.has_more || false,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load TLD data:', error)
|
||||
setTlds([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTrending = async () => {
|
||||
try {
|
||||
const data = await api.getTrendingTlds()
|
||||
setTrending(data?.trending || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load trending:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if TLD should show full data for non-authenticated users
|
||||
// Gemäß pounce_public.md: .com, .net, .org are fully visible
|
||||
const isPublicPreviewTld = (tld: TldData) => {
|
||||
return PUBLIC_PREVIEW_TLDS.includes(tld.tld.toLowerCase())
|
||||
}
|
||||
|
||||
const getRiskBadge = (tld: TldData) => {
|
||||
const level = tld.risk_level || 'low'
|
||||
const reason = tld.risk_reason || 'Stable'
|
||||
return (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
level === 'high' && "bg-red-500/10 text-red-400",
|
||||
level === 'medium' && "bg-amber-500/10 text-amber-400",
|
||||
level === 'low' && "bg-accent/10 text-accent"
|
||||
)}>
|
||||
<span className={clsx(
|
||||
"w-2.5 h-2.5 rounded-full",
|
||||
level === 'high' && "bg-red-400",
|
||||
level === 'medium' && "bg-amber-400",
|
||||
level === 'low' && "bg-accent"
|
||||
)} />
|
||||
<span className="hidden sm:inline ml-1">{reason}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const getRenewalTrap = (tld: TldData) => {
|
||||
if (!tld.min_renewal_price || !tld.min_registration_price) return null
|
||||
const ratio = tld.min_renewal_price / tld.min_registration_price
|
||||
if (ratio > 2) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-amber-400" title={`Renewal is ${ratio.toFixed(1)}x the registration price`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const currentPage = Math.floor(pagination.offset / pagination.limit) + 1
|
||||
const totalPages = Math.ceil(pagination.total / pagination.limit)
|
||||
|
||||
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 overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<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 - gemäß pounce_public.md: "TLD Market Inflation Monitor" */}
|
||||
<div className="text-center mb-12 sm:mb-16 animate-fade-in">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 text-accent text-sm mb-6">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>Real-time Market Data</span>
|
||||
</div>
|
||||
<h1 className="font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
|
||||
TLD Market
|
||||
<span className="block text-accent">Inflation Monitor</span>
|
||||
</h1>
|
||||
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
||||
Don't fall for promo prices. See renewal costs, spot traps, and track price trends across {pagination.total}+ extensions.
|
||||
</p>
|
||||
|
||||
{/* Top Movers Cards */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 mt-8">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-foreground-muted">Renewal Trap Detection</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-accent" />
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<span className="w-2 h-2 rounded-full bg-red-400" />
|
||||
</div>
|
||||
<span className="text-foreground-muted">Risk Levels</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm">
|
||||
<TrendingUp className="w-4 h-4 text-orange-400" />
|
||||
<span className="text-foreground-muted">1y/3y Trends</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Banner for non-authenticated users */}
|
||||
{!isAuthenticated && (
|
||||
<div className="mb-8 p-6 bg-gradient-to-r from-accent/10 to-accent/5 border border-accent/20 rounded-2xl animate-fade-in">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-accent/20 rounded-xl flex items-center justify-center">
|
||||
<Lock className="w-6 h-6 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Stop overpaying. Know the true costs.</p>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
Unlock renewal prices and risk analysis for all {pagination.total}+ TLDs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/register"
|
||||
className="shrink-0 px-6 py-3 bg-accent text-background font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
|
||||
>
|
||||
Start Hunting
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trending Section - Top Movers */}
|
||||
{trending.length > 0 && (
|
||||
<div className="mb-12 sm:mb-16 animate-slide-up">
|
||||
<h2 className="text-body-lg sm:text-heading-sm font-medium text-foreground mb-4 sm:mb-6 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-accent" />
|
||||
Top Movers
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
{trending.map((item) => (
|
||||
<Link
|
||||
key={item.tld}
|
||||
href={isAuthenticated ? `/intel/${item.tld}` : '/register'}
|
||||
className="p-4 sm:p-5 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover hover:bg-background-secondary transition-all duration-300 text-left group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-mono text-body-lg sm:text-heading-sm text-foreground">.{item.tld}</span>
|
||||
<span className={clsx(
|
||||
"text-ui-sm font-medium px-2 py-0.5 rounded-full",
|
||||
item.price_change > 0
|
||||
? "text-[#f97316] bg-[#f9731615]"
|
||||
: "text-accent bg-accent-muted"
|
||||
)}>
|
||||
{item.price_change > 0 ? '+' : ''}{item.price_change.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-body-sm text-foreground-muted mb-2 line-clamp-2">
|
||||
{item.reason}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-body-sm text-foreground-subtle">
|
||||
${item.current_price.toFixed(2)}/yr
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search & Sort Controls */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6 animate-slide-up">
|
||||
<div className="relative flex-1 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 TLDs (e.g., com, io, ai)..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
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('')
|
||||
setPage(0)
|
||||
}}
|
||||
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>
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
className="appearance-none pl-4 pr-10 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
|
||||
transition-all cursor-pointer min-w-[180px]"
|
||||
>
|
||||
<option value="popularity">Most Popular</option>
|
||||
<option value="name">Alphabetical</option>
|
||||
<option value="price_asc">Price: Low → High</option>
|
||||
<option value="price_desc">Price: High → Low</option>
|
||||
</select>
|
||||
<ArrowUpDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TLD Table - gemäß pounce_public.md:
|
||||
- .com, .net, .org: vollständig sichtbar
|
||||
- Alle anderen: Buy Price + Trend sichtbar, Renewal + Risk geblurrt */}
|
||||
<PremiumTable
|
||||
data={tlds}
|
||||
keyExtractor={(tld) => tld.tld}
|
||||
loading={loading}
|
||||
onRowClick={(tld) => {
|
||||
if (isAuthenticated) {
|
||||
window.location.href = `/intel/${tld.tld}`
|
||||
} else {
|
||||
window.location.href = `/login?redirect=/intel/${tld.tld}`
|
||||
}
|
||||
}}
|
||||
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />}
|
||||
emptyTitle="No TLDs found"
|
||||
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
|
||||
columns={[
|
||||
{
|
||||
key: 'tld',
|
||||
header: 'TLD',
|
||||
width: '100px',
|
||||
render: (tld) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||
.{tld.tld}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'buy_price',
|
||||
header: 'Current Price',
|
||||
align: 'right',
|
||||
width: '120px',
|
||||
// Buy price is visible for all TLDs (gemäß pounce_public.md)
|
||||
render: (tld) => (
|
||||
<span className="font-semibold text-foreground tabular-nums">
|
||||
${tld.min_registration_price.toFixed(2)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'trend',
|
||||
header: 'Trend (1y)',
|
||||
width: '100px',
|
||||
hideOnMobile: true,
|
||||
// Trend is visible for all TLDs
|
||||
render: (tld) => {
|
||||
const change = tld.price_change_1y || 0
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkline trend={change} />
|
||||
<span className={clsx(
|
||||
"font-medium tabular-nums text-sm",
|
||||
change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted"
|
||||
)}>
|
||||
{change > 0 ? '+' : ''}{change.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'renew_price',
|
||||
header: 'Renewal Price',
|
||||
align: 'right',
|
||||
width: '130px',
|
||||
// Renewal price: visible for .com/.net/.org OR authenticated users
|
||||
// Geblurrt/Locked für alle anderen
|
||||
render: (tld) => {
|
||||
const showData = isAuthenticated || isPublicPreviewTld(tld)
|
||||
if (!showData) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<span className="text-foreground-muted blur-[3px] select-none">$XX.XX</span>
|
||||
<Lock className="w-3 h-3 text-foreground-subtle" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<span className="text-foreground-muted tabular-nums">
|
||||
${tld.min_renewal_price?.toFixed(2) || '—'}
|
||||
</span>
|
||||
{getRenewalTrap(tld)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'risk',
|
||||
header: 'Risk Level',
|
||||
align: 'center',
|
||||
width: '140px',
|
||||
// Risk: visible for .com/.net/.org OR authenticated users
|
||||
// Geblurrt/Locked für alle anderen
|
||||
render: (tld) => {
|
||||
const showData = isAuthenticated || isPublicPreviewTld(tld)
|
||||
if (!showData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-foreground/5 blur-[3px] select-none">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-foreground-subtle" />
|
||||
<span className="hidden sm:inline ml-1">Hidden</span>
|
||||
</span>
|
||||
<Lock className="w-3 h-3 text-foreground-subtle" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return getRiskBadge(tld)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right',
|
||||
width: '80px',
|
||||
render: () => (
|
||||
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && pagination.total > pagination.limit && (
|
||||
<div className="flex items-center justify-center gap-4 pt-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-foreground-muted tabular-nums">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={!pagination.has_more}
|
||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{!loading && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<p className="text-ui-sm text-foreground-subtle">
|
||||
{searchQuery
|
||||
? `Found ${pagination.total} TLDs matching "${searchQuery}"`
|
||||
: `${pagination.total} TLDs tracked`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,25 +1,730 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { PlatformBadge } from '@/components/PremiumTable'
|
||||
import {
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Search,
|
||||
Flame,
|
||||
Timer,
|
||||
Gavel,
|
||||
DollarSign,
|
||||
X,
|
||||
Lock,
|
||||
TrendingUp,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Sparkles,
|
||||
Diamond,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
Filter,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
/**
|
||||
* Redirect /market to /auctions
|
||||
* This page is kept for backwards compatibility
|
||||
*/
|
||||
export default function MarketRedirect() {
|
||||
const router = useRouter()
|
||||
interface MarketItem {
|
||||
id: string
|
||||
domain: string
|
||||
tld: string
|
||||
price: number
|
||||
currency: string
|
||||
price_type: 'bid' | 'fixed' | 'negotiable'
|
||||
status: 'auction' | 'instant'
|
||||
source: string
|
||||
is_pounce: boolean
|
||||
verified: boolean
|
||||
time_remaining?: string
|
||||
end_time?: string
|
||||
num_bids?: number
|
||||
slug?: string
|
||||
seller_verified: boolean
|
||||
url: string
|
||||
is_external: boolean
|
||||
pounce_score: number
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type TabType = 'all' | 'ending' | 'hot'
|
||||
type SortField = 'domain' | 'ending' | 'bid' | 'bids'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'All', name: 'All Sources' },
|
||||
{ id: 'GoDaddy', name: 'GoDaddy' },
|
||||
{ id: 'Sedo', name: 'Sedo' },
|
||||
{ id: 'NameJet', name: 'NameJet' },
|
||||
{ id: 'DropCatch', name: 'DropCatch' },
|
||||
]
|
||||
|
||||
// Premium TLDs that look professional
|
||||
const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'de', 'ch', 'net', 'org', 'app', 'dev', 'xyz']
|
||||
|
||||
// Vanity Filter: Only show "beautiful" domains to non-authenticated users
|
||||
// Rules: No numbers (except short domains), no hyphens, length < 12, only premium TLDs
|
||||
function isVanityDomain(auction: Auction): boolean {
|
||||
const domain = auction.domain
|
||||
const parts = domain.split('.')
|
||||
if (parts.length < 2) return false
|
||||
|
||||
const name = parts[0]
|
||||
const tld = parts.slice(1).join('.').toLowerCase()
|
||||
|
||||
// Check TLD is premium
|
||||
if (!PREMIUM_TLDS.includes(tld)) return false
|
||||
|
||||
// Check length (max 12 characters for the name)
|
||||
if (name.length > 12) return false
|
||||
|
||||
// No hyphens
|
||||
if (name.includes('-')) return false
|
||||
|
||||
// No numbers (unless domain is 4 chars or less - short domains are valuable)
|
||||
if (name.length > 4 && /\d/.test(name)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Generate a mock "Deal Score" for display purposes
|
||||
// In production, this would come from a valuation API
|
||||
function getDealScore(auction: Auction): number | null {
|
||||
let score = 50
|
||||
|
||||
const name = auction.domain.split('.')[0]
|
||||
if (name.length <= 4) score += 20
|
||||
else if (name.length <= 6) score += 10
|
||||
|
||||
if (['com', 'io', 'ai'].includes(auction.tld)) score += 15
|
||||
|
||||
if (auction.age_years && auction.age_years > 5) score += 10
|
||||
|
||||
if (auction.num_bids >= 20) score += 15
|
||||
else if (auction.num_bids >= 10) score += 10
|
||||
|
||||
return Math.min(score, 100)
|
||||
}
|
||||
|
||||
function SortIcon({ field, currentField, direction }: { field: SortField, currentField: SortField, direction: SortDirection }) {
|
||||
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" />
|
||||
}
|
||||
|
||||
export default function MarketPage() {
|
||||
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
||||
|
||||
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
|
||||
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
|
||||
const [hotAuctions, setHotAuctions] = useState<Auction[]>([])
|
||||
const [pounceItems, setPounceItems] = useState<MarketItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all')
|
||||
const [sortField, setSortField] = useState<SortField>('ending')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedPlatform, setSelectedPlatform] = useState('All')
|
||||
const [maxBid, setMaxBid] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
router.replace('/auctions')
|
||||
}, [router])
|
||||
checkAuth()
|
||||
loadAuctions()
|
||||
}, [checkAuth])
|
||||
|
||||
const loadAuctions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [allFeed, endingFeed, hotFeed, pounceFeed] = await Promise.all([
|
||||
api.getMarketFeed({ source: 'all', limit: 100, sortBy: 'time' }),
|
||||
api.getMarketFeed({ source: 'external', endingWithin: 24, limit: 50, sortBy: 'time' }),
|
||||
api.getMarketFeed({ source: 'external', limit: 50, sortBy: 'score' }),
|
||||
api.getMarketFeed({ source: 'pounce', limit: 10 }),
|
||||
])
|
||||
|
||||
const convertToAuction = (item: MarketItem): Auction => ({
|
||||
domain: item.domain,
|
||||
platform: item.source,
|
||||
platform_url: item.url,
|
||||
current_bid: item.price,
|
||||
currency: item.currency,
|
||||
num_bids: item.num_bids || 0,
|
||||
end_time: item.end_time || '',
|
||||
time_remaining: item.time_remaining || '',
|
||||
buy_now_price: item.price_type === 'fixed' ? item.price : null,
|
||||
reserve_met: null,
|
||||
traffic: null,
|
||||
age_years: null,
|
||||
tld: item.tld,
|
||||
affiliate_url: item.url,
|
||||
})
|
||||
|
||||
const externalOnly = (items: MarketItem[]) => items.filter(i => !i.is_pounce).map(convertToAuction)
|
||||
|
||||
setAllAuctions(externalOnly(allFeed.items || []))
|
||||
setEndingSoon(externalOnly(endingFeed.items || []))
|
||||
setHotAuctions(externalOnly(hotFeed.items || []))
|
||||
setPounceItems(pounceFeed.items || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load auctions:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentAuctions = (): Auction[] => {
|
||||
switch (activeTab) {
|
||||
case 'ending': return endingSoon
|
||||
case 'hot': return hotAuctions
|
||||
default: return allAuctions
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Vanity Filter for non-authenticated users
|
||||
const displayAuctions = useMemo(() => {
|
||||
const current = getCurrentAuctions()
|
||||
if (isAuthenticated) {
|
||||
return current
|
||||
}
|
||||
return current.filter(isVanityDomain)
|
||||
}, [activeTab, allAuctions, endingSoon, hotAuctions, isAuthenticated])
|
||||
|
||||
const filteredAuctions = displayAuctions.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 handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedAuctions = [...filteredAuctions].sort((a, b) => {
|
||||
const modifier = sortDirection === 'asc' ? 1 : -1
|
||||
switch (sortField) {
|
||||
case 'domain':
|
||||
return a.domain.localeCompare(b.domain) * modifier
|
||||
case 'bid':
|
||||
return (a.current_bid - b.current_bid) * modifier
|
||||
case 'bids':
|
||||
return (a.num_bids - b.num_bids) * modifier
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
const formatCurrency = (amount: number, currency = 'USD') => {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
|
||||
}
|
||||
|
||||
const getTimeColor = (timeRemaining: string) => {
|
||||
if (timeRemaining.includes('m') && !timeRemaining.includes('h')) return 'text-red-400'
|
||||
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 12) return 'text-amber-400'
|
||||
return 'text-foreground-muted'
|
||||
}
|
||||
|
||||
const hotPreview = hotAuctions.slice(0, 4)
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-foreground-muted">Redirecting to Market...</p>
|
||||
</div>
|
||||
<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 overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<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">
|
||||
{/* Hero Header - gemäß pounce_public.md */}
|
||||
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Live Market</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">
|
||||
Live Domain Market
|
||||
</h1>
|
||||
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
||||
Aggregated from GoDaddy, Sedo, and Pounce Direct.
|
||||
</p>
|
||||
{!isAuthenticated && displayAuctions.length < allAuctions.length && (
|
||||
<p className="mt-2 text-sm text-accent flex items-center justify-center gap-1">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Showing {displayAuctions.length} premium domains • Sign in to see all {allAuctions.length}
|
||||
</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">
|
||||
<Lock 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 valuations, deal scores, and personalized recommendations.
|
||||
</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"
|
||||
>
|
||||
Start Hunting
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pounce Direct Section - Featured */}
|
||||
{pounceItems.length > 0 && (
|
||||
<div className="mb-12 sm:mb-16 animate-slide-up">
|
||||
<div className="flex items-center gap-3 mb-4 sm:mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Diamond className="w-5 h-5 text-accent fill-accent/20" />
|
||||
<h2 className="text-body-lg sm:text-heading-sm font-medium text-foreground">
|
||||
Pounce Direct
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-ui-sm text-foreground-subtle">Verified • Instant Buy • 0% Commission</span>
|
||||
</div>
|
||||
<div className="border border-accent/20 rounded-xl overflow-hidden bg-gradient-to-br from-accent/5 to-transparent">
|
||||
{pounceItems.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.url}
|
||||
className="flex items-center justify-between px-5 py-4 border-b border-accent/10 last:border-b-0 hover:bg-accent/5 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Diamond className="w-5 h-5 text-accent fill-accent/20 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-mono text-body font-medium text-foreground group-hover:text-accent transition-colors">
|
||||
{item.domain}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{item.verified && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-accent bg-accent/10 px-2 py-0.5 rounded-full">
|
||||
<ShieldCheck className="w-3 h-3" />
|
||||
Verified
|
||||
</span>
|
||||
)}
|
||||
{isAuthenticated ? (
|
||||
<span className="text-ui-sm text-foreground-subtle">
|
||||
Score: {item.pounce_score}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-ui-sm text-foreground-subtle blur-[4px]">
|
||||
Score: XX
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-body-lg font-medium text-foreground">
|
||||
{formatCurrency(item.price, item.currency)}
|
||||
</div>
|
||||
<div className="text-ui-sm text-accent">Instant Buy</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-accent text-background rounded-lg text-ui-sm font-bold opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Buy Now
|
||||
<Zap className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 text-center">
|
||||
<Link
|
||||
href="/buy"
|
||||
className="text-ui-sm text-accent hover:underline"
|
||||
>
|
||||
Browse all Pounce listings →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hot Auctions Preview */}
|
||||
{hotPreview.length > 0 && (
|
||||
<div className="mb-12 sm:mb-16 animate-slide-up">
|
||||
<h2 className="text-body-lg sm:text-heading-sm font-medium text-foreground mb-4 sm:mb-6 flex items-center gap-2">
|
||||
<Flame className="w-5 h-5 text-accent" />
|
||||
Hot Right Now
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
{hotPreview.map((auction) => (
|
||||
<a
|
||||
key={`${auction.domain}-${auction.platform}`}
|
||||
href={auction.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-4 sm:p-5 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover hover:bg-background-secondary transition-all duration-300 text-left group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-mono text-body-lg sm:text-heading-sm text-foreground group-hover:text-accent transition-colors">
|
||||
{auction.domain}
|
||||
</span>
|
||||
<span className="text-ui-sm font-medium px-2 py-0.5 rounded-full text-accent bg-accent-muted flex items-center gap-1">
|
||||
<Flame className="w-3 h-3" />
|
||||
{auction.num_bids}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-body-sm text-foreground-muted">
|
||||
{formatCurrency(auction.current_bid)}
|
||||
</span>
|
||||
<span className={clsx("text-body-sm", getTimeColor(auction.time_remaining))}>
|
||||
{auction.time_remaining}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<PlatformBadge platform={auction.platform} />
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</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)}
|
||||
className="px-4 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||
text-body text-foreground cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
>
|
||||
{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-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max bid"
|
||||
value={maxBid}
|
||||
onChange={(e) => setMaxBid(e.target.value)}
|
||||
className="w-32 pl-10 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap gap-2 mb-6 animate-slide-up">
|
||||
{[
|
||||
{ id: 'all' as const, label: 'All Auctions', icon: Gavel, count: allAuctions.length },
|
||||
{ id: 'ending' as const, label: 'Ending Soon', icon: Timer, count: endingSoon.length },
|
||||
{ id: 'hot' as const, label: 'Hot', icon: Flame, count: hotAuctions.length },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-xl transition-all",
|
||||
activeTab === tab.id
|
||||
? "bg-accent text-background"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground hover:bg-background-secondary border border-border"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
<span className={clsx(
|
||||
"text-xs px-1.5 py-0.5 rounded",
|
||||
activeTab === tab.id ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{tab.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Auctions Table */}
|
||||
<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('domain')}
|
||||
className="flex items-center gap-2 text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Domain
|
||||
<SortIcon field="domain" currentField={sortField} 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">Source</span>
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-4">
|
||||
<button
|
||||
onClick={() => handleSort('bid')}
|
||||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Price
|
||||
<SortIcon field="bid" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
{/* Pounce Score Column - visible but blurred for non-auth (gemäß pounce_public.md) */}
|
||||
<th className="text-center px-4 sm:px-6 py-4 hidden md:table-cell">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium flex items-center justify-center gap-1">
|
||||
Pounce Score
|
||||
{!isAuthenticated && <Lock className="w-3 h-3" />}
|
||||
</span>
|
||||
</th>
|
||||
{/* Valuation Column - visible but blurred for non-auth */}
|
||||
<th className="text-center px-4 sm:px-6 py-4 hidden lg:table-cell">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium flex items-center justify-center gap-1">
|
||||
Valuation
|
||||
{!isAuthenticated && <Lock className="w-3 h-3" />}
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-4 hidden md:table-cell">
|
||||
<button
|
||||
onClick={() => handleSort('ending')}
|
||||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Time Left
|
||||
<SortIcon field="ending" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading ? (
|
||||
Array.from({ length: 10 }).map((_, idx) => (
|
||||
<tr key={idx} className="animate-pulse">
|
||||
<td className="px-4 sm:px-6 py-4"><div className="h-4 w-32 bg-background-tertiary rounded" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell"><div className="h-4 w-20 bg-background-tertiary rounded" /></td>
|
||||
<td className="px-4 sm:px-6 py-4"><div className="h-4 w-16 bg-background-tertiary rounded ml-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden md:table-cell"><div className="h-8 w-8 bg-background-tertiary rounded mx-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell"><div className="h-4 w-16 bg-background-tertiary rounded mx-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden md:table-cell"><div className="h-4 w-16 bg-background-tertiary rounded ml-auto" /></td>
|
||||
<td className="px-4 sm:px-6 py-4"><div className="h-8 w-16 bg-background-tertiary rounded ml-auto" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : sortedAuctions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-foreground-muted">
|
||||
{searchQuery ? `No domains found matching "${searchQuery}"` : 'No domains found'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedAuctions.map((auction) => (
|
||||
<tr
|
||||
key={`${auction.domain}-${auction.platform}`}
|
||||
className="hover:bg-background-secondary/50 transition-colors group"
|
||||
>
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<div>
|
||||
<a
|
||||
href={auction.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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 mt-1 lg:hidden">
|
||||
<PlatformBadge platform={auction.platform} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell">
|
||||
<div className="space-y-1">
|
||||
<PlatformBadge platform={auction.platform} />
|
||||
{auction.age_years && (
|
||||
<span className="text-ui-sm text-foreground-subtle flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> {auction.age_years}y
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<div>
|
||||
<span className="text-body-sm font-medium text-foreground">
|
||||
{formatCurrency(auction.current_bid)}
|
||||
</span>
|
||||
{auction.buy_now_price && (
|
||||
<p className="text-ui-sm text-accent">Buy: {formatCurrency(auction.buy_now_price)}</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
{/* Pounce Score - blurred for non-authenticated (gemäß pounce_public.md) */}
|
||||
<td className="px-4 sm:px-6 py-4 text-center hidden md:table-cell">
|
||||
{isAuthenticated ? (
|
||||
<div className="inline-flex flex-col items-center">
|
||||
<span className={clsx(
|
||||
"inline-flex items-center justify-center w-9 h-9 rounded-lg font-bold text-sm",
|
||||
(getDealScore(auction) ?? 0) >= 75 ? "bg-accent/20 text-accent" :
|
||||
(getDealScore(auction) ?? 0) >= 50 ? "bg-amber-500/20 text-amber-400" :
|
||||
"bg-foreground/10 text-foreground-muted"
|
||||
)}>
|
||||
{getDealScore(auction)}
|
||||
</span>
|
||||
{(getDealScore(auction) ?? 0) >= 75 && (
|
||||
<span className="text-[10px] text-accent mt-0.5 font-medium">Good Deal</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-lg bg-foreground/5 text-foreground-muted blur-[3px] cursor-pointer"
|
||||
title="Sign in to unlock valuations"
|
||||
>
|
||||
<span className="font-bold text-sm">XX</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{/* Valuation - blurred for non-authenticated */}
|
||||
<td className="px-4 sm:px-6 py-4 text-center hidden lg:table-cell">
|
||||
{isAuthenticated ? (
|
||||
<span className="text-body-sm font-medium text-foreground">
|
||||
${(auction.current_bid * 1.5).toFixed(0)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-body-sm font-medium text-foreground-muted blur-[3px]">
|
||||
$X,XXX
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 text-right hidden md:table-cell">
|
||||
<span className={clsx("font-medium", getTimeColor(auction.time_remaining))}>
|
||||
{auction.time_remaining}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<a
|
||||
href={auction.affiliate_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-ui-sm text-accent hover:text-accent-hover transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
Bid
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{!loading && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<p className="text-ui-sm text-foreground-subtle">
|
||||
{searchQuery
|
||||
? `Found ${sortedAuctions.length} domains matching "${searchQuery}"`
|
||||
: `${allAuctions.length} domains available across ${PLATFORMS.length - 1} platforms`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom CTA for upgrade (gemäß pounce_public.md) */}
|
||||
{!isAuthenticated && (
|
||||
<div className="mt-12 p-6 sm:p-8 bg-gradient-to-r from-accent/10 to-accent/5 border border-accent/20 rounded-2xl text-center animate-slide-up">
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<Filter className="w-5 h-5 text-accent" />
|
||||
<h3 className="text-lg font-medium text-foreground">Tired of digging through spam?</h3>
|
||||
</div>
|
||||
<p className="text-foreground-muted mb-5 max-w-lg mx-auto">
|
||||
Our 'Trader' plan filters 99% of junk domains automatically. See only premium opportunities.
|
||||
</p>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background rounded-xl font-medium
|
||||
hover:bg-accent-hover transition-all shadow-[0_0_20px_rgba(16,185,129,0.2)]"
|
||||
>
|
||||
Upgrade Filter
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -261,6 +261,82 @@ export default function HomePage() {
|
||||
<MarketTicker auctions={hotAuctions} />
|
||||
)}
|
||||
|
||||
{/* Live Market Teaser - gemäß pounce_public.md */}
|
||||
{!isAuthenticated && !loadingAuctions && hotAuctions.length > 0 && (
|
||||
<section className="relative py-12 sm:py-16 px-4 sm:px-6 bg-background-secondary/30">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-medium text-foreground flex items-center gap-2">
|
||||
<Gavel className="w-5 h-5 text-accent" />
|
||||
Live Market Preview
|
||||
</h3>
|
||||
<Link
|
||||
href="/market"
|
||||
className="text-sm text-accent hover:text-accent-hover transition-colors flex items-center gap-1"
|
||||
>
|
||||
View All <ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mini Table with blur on last row */}
|
||||
<div className="bg-background border border-border rounded-xl overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-background-secondary/50">
|
||||
<th className="text-left px-4 py-3 text-xs text-foreground-subtle font-medium uppercase tracking-wider">Domain</th>
|
||||
<th className="text-right px-4 py-3 text-xs text-foreground-subtle font-medium uppercase tracking-wider hidden sm:table-cell">Price</th>
|
||||
<th className="text-right px-4 py-3 text-xs text-foreground-subtle font-medium uppercase tracking-wider">Time Left</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{hotAuctions.slice(0, 4).map((auction, idx) => (
|
||||
<tr key={`${auction.domain}-${idx}`} className="hover:bg-background-secondary/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-sm text-foreground">{auction.domain}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right hidden sm:table-cell">
|
||||
<span className="text-sm text-foreground">${auction.current_bid}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="text-sm text-foreground-muted">{auction.time_remaining}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* Blurred last row - the hook */}
|
||||
{hotAuctions.length > 4 && (
|
||||
<tr className="relative">
|
||||
<td colSpan={3} className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-3 blur-[4px] select-none pointer-events-none">
|
||||
<span className="font-mono text-sm text-foreground-muted">{hotAuctions[4]?.domain || 'premium.io'}</span>
|
||||
<span className="text-sm text-foreground-muted">$XXX</span>
|
||||
<span className="text-sm text-foreground-subtle">Xh Xm</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Sign in CTA overlay */}
|
||||
<div className="border-t border-border bg-gradient-to-r from-accent/5 to-accent/10 px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Lock className="w-4 h-4 text-accent" />
|
||||
<span className="text-sm text-foreground-muted">
|
||||
Sign in to see <span className="text-accent font-medium">{hotAuctions.length - 4}+ more domains</span>
|
||||
</span>
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-sm text-accent hover:text-accent-hover font-medium transition-colors"
|
||||
>
|
||||
Start Hunting →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Three Pillars: DISCOVER, TRACK, ACQUIRE */}
|
||||
<section className="relative py-24 sm:py-32 px-4 sm:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
@ -582,10 +658,10 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/tld-pricing"
|
||||
href="/intel"
|
||||
className="group inline-flex items-center gap-2 px-5 py-2.5 bg-foreground/5 border border-border rounded-xl text-sm font-medium text-foreground hover:border-accent hover:text-accent transition-all"
|
||||
>
|
||||
Explore TLD Pricing
|
||||
Explore Intel
|
||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
@ -606,7 +682,7 @@ export default function HomePage() {
|
||||
{trendingTlds.map((item, index) => (
|
||||
<Link
|
||||
key={item.tld}
|
||||
href={isAuthenticated ? `/tld-pricing/${item.tld}` : `/login?redirect=/tld-pricing/${item.tld}`}
|
||||
href={isAuthenticated ? `/intel/${item.tld}` : `/login?redirect=/intel/${item.tld}`}
|
||||
className="group relative p-6 bg-background border border-border rounded-2xl
|
||||
hover:border-accent/30 transition-all duration-300"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
|
||||
@ -19,12 +19,12 @@ const tiers = [
|
||||
period: '',
|
||||
description: 'Test the waters. Zero risk.',
|
||||
features: [
|
||||
{ text: '5 domains to track', highlight: false, available: true },
|
||||
{ text: 'Daily availability scans', highlight: false, available: true },
|
||||
{ text: 'Email alerts', highlight: false, available: true },
|
||||
{ text: 'Raw auction feed', highlight: false, available: true, sublabel: 'Unfiltered' },
|
||||
{ text: '2 domain listings', highlight: false, available: true, sublabel: 'For Sale' },
|
||||
{ text: 'Deal scores & valuations', highlight: false, available: false },
|
||||
{ text: 'Market Overview', highlight: false, available: true },
|
||||
{ text: 'Basic Search', highlight: false, available: true },
|
||||
{ text: '5 Watchlist Domains', highlight: false, available: true },
|
||||
{ text: 'Market Feed', highlight: false, available: true, sublabel: '🌪️ Raw' },
|
||||
{ text: 'Alert Speed', highlight: false, available: true, sublabel: '🐢 Daily' },
|
||||
{ text: 'Pounce Score', highlight: false, available: false },
|
||||
{ text: 'Sniper Alerts', highlight: false, available: false },
|
||||
],
|
||||
cta: 'Start Free',
|
||||
@ -40,14 +40,14 @@ const tiers = [
|
||||
period: '/mo',
|
||||
description: 'The smart investor\'s choice.',
|
||||
features: [
|
||||
{ text: '50 domains to track', highlight: true, available: true },
|
||||
{ text: 'Hourly scans', highlight: true, available: true, sublabel: '24x faster' },
|
||||
{ text: 'Smart spam filter', highlight: true, available: true, sublabel: 'Curated list' },
|
||||
{ text: 'Deal scores & valuations', highlight: true, available: true },
|
||||
{ text: '10 domain listings', highlight: true, available: true, sublabel: 'For Sale' },
|
||||
{ text: '50 Watchlist Domains', highlight: true, available: true },
|
||||
{ text: 'Market Feed', highlight: true, available: true, sublabel: '✨ Curated' },
|
||||
{ text: 'Alert Speed', highlight: true, available: true, sublabel: '🐇 Hourly' },
|
||||
{ text: 'Renewal Price Intel', highlight: true, available: true },
|
||||
{ text: 'Pounce Score', highlight: true, available: true },
|
||||
{ text: 'List domains', highlight: true, available: true, sublabel: '0% Fee' },
|
||||
{ text: '5 Sniper Alerts', highlight: true, available: true },
|
||||
{ text: 'Portfolio tracking (25)', highlight: true, available: true },
|
||||
{ text: 'Expiry date tracking', highlight: true, available: true },
|
||||
{ text: 'Portfolio (25 domains)', highlight: true, available: true },
|
||||
],
|
||||
cta: 'Upgrade to Trader',
|
||||
highlighted: true,
|
||||
@ -62,14 +62,14 @@ const tiers = [
|
||||
period: '/mo',
|
||||
description: 'For serious domain investors.',
|
||||
features: [
|
||||
{ text: '500 domains to track', highlight: true, available: true },
|
||||
{ text: 'Real-time scans', highlight: true, available: true, sublabel: 'Every 10 min' },
|
||||
{ text: '50 domain listings', highlight: true, available: true, sublabel: 'For Sale' },
|
||||
{ text: '500 Watchlist Domains', highlight: true, available: true },
|
||||
{ text: 'Market Feed', highlight: true, available: true, sublabel: '⚡ Priority' },
|
||||
{ text: 'Alert Speed', highlight: true, available: true, sublabel: '⚡ 10 min' },
|
||||
{ text: 'Full Portfolio Monitor', highlight: true, available: true },
|
||||
{ text: 'Score + SEO Data', highlight: true, available: true },
|
||||
{ text: 'Featured Listings', highlight: true, available: true, sublabel: '50 slots' },
|
||||
{ text: 'Unlimited Sniper Alerts', highlight: true, available: true },
|
||||
{ text: 'SEO Juice Detector', highlight: true, available: true, sublabel: 'Backlinks' },
|
||||
{ text: 'Unlimited portfolio', highlight: true, available: true },
|
||||
{ text: 'Full price history', highlight: true, available: true },
|
||||
{ text: 'API access', highlight: true, available: true, sublabel: 'Coming soon' },
|
||||
{ text: 'Full Price History', highlight: true, available: true },
|
||||
],
|
||||
cta: 'Go Tycoon',
|
||||
highlighted: false,
|
||||
@ -79,16 +79,14 @@ const tiers = [
|
||||
]
|
||||
|
||||
const comparisonFeatures = [
|
||||
{ name: 'Watchlist Domains', scout: '5', trader: '50', tycoon: '500' },
|
||||
{ name: 'Check Frequency', scout: 'Daily', trader: 'Hourly', tycoon: '10 min' },
|
||||
{ name: 'Auction Feed', scout: 'Raw (unfiltered)', trader: 'Curated (spam-free)', tycoon: 'Curated (spam-free)' },
|
||||
{ name: 'Deal Scores', scout: '—', trader: 'check', tycoon: 'check' },
|
||||
{ name: 'For Sale Listings', scout: '2', trader: '10', tycoon: '50' },
|
||||
{ name: 'Market Feed', scout: '🌪️ Raw', trader: '✨ Curated', tycoon: '✨ Priority' },
|
||||
{ name: 'Alert Speed', scout: '🐢 Daily', trader: '🐇 Hourly', tycoon: '⚡ 10 min' },
|
||||
{ name: 'Watchlist', scout: '5 Domains', trader: '50 Domains', tycoon: '500 Domains' },
|
||||
{ name: 'Marketplace', scout: 'Buy Only', trader: 'Sell (0% Fee)', tycoon: 'Sell + Featured' },
|
||||
{ name: 'TLD Intel', scout: 'Public Trends', trader: 'Renewal Prices', tycoon: 'Full History' },
|
||||
{ name: 'Valuation', scout: '❌ Locked', trader: '✅ Pounce Score', tycoon: '✅ Score + SEO' },
|
||||
{ name: 'Sniper Alerts', scout: '—', trader: '5', tycoon: 'Unlimited' },
|
||||
{ name: 'Portfolio Domains', scout: '—', trader: '25', tycoon: 'Unlimited' },
|
||||
{ name: 'SEO Juice Detector', scout: '—', trader: '—', tycoon: 'check' },
|
||||
{ name: 'Price History', scout: '—', trader: '90 days', tycoon: 'Unlimited' },
|
||||
{ name: 'Expiry Tracking', scout: '—', trader: 'check', tycoon: 'check' },
|
||||
{ name: 'Portfolio', scout: '—', trader: '25 Domains', tycoon: 'Unlimited' },
|
||||
]
|
||||
|
||||
const faqs = [
|
||||
|
||||
@ -20,12 +20,12 @@ import {
|
||||
DollarSign,
|
||||
X,
|
||||
Tag,
|
||||
Store,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
TrendingUp,
|
||||
Globe,
|
||||
MoreHorizontal
|
||||
MoreHorizontal,
|
||||
Crown
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
@ -356,13 +356,6 @@ export default function MyListingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href="/buy"
|
||||
className="px-4 py-2 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 text-sm font-medium text-zinc-300 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Store className="w-4 h-4" /> Marketplace
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={listings.length >= maxListings}
|
||||
@ -371,7 +364,6 @@ export default function MyListingsPage() {
|
||||
<Plus className="w-4 h-4" /> New Listing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
@ -396,15 +388,37 @@ export default function MyListingsPage() {
|
||||
<div className="absolute inset-0 bg-grid-white/[0.02] bg-[length:20px_20px]" />
|
||||
<div className="relative z-10">
|
||||
<Shield className="w-12 h-12 text-emerald-400 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Unlock Portfolio Management</h2>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Unlock Domain Selling</h2>
|
||||
<p className="text-zinc-400 mb-6 max-w-md mx-auto">
|
||||
List your domains, verify ownership automatically, and sell directly to buyers with 0% commission on the Pounce Marketplace.
|
||||
List your domains with 0% commission on the Pounce Marketplace.
|
||||
</p>
|
||||
|
||||
{/* Plan comparison */}
|
||||
<div className="grid grid-cols-2 gap-4 max-w-sm mx-auto mb-6">
|
||||
<div className="p-4 bg-white/5 border border-white/10 rounded-xl text-left">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-sm font-bold text-emerald-400">Trader</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 mb-1">$9/month</p>
|
||||
<p className="text-white font-bold">5 Listings</p>
|
||||
</div>
|
||||
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl text-left">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Crown className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-sm font-bold text-amber-400">Tycoon</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 mb-1">$29/month</p>
|
||||
<p className="text-white font-bold">50 Listings</p>
|
||||
<p className="text-[10px] text-amber-400 mt-1">+ Featured Badge</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-emerald-500 text-white font-bold rounded-xl hover:bg-emerald-400 transition-all shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
Upgrade to Trader <ArrowRight className="w-4 h-4" />
|
||||
Upgrade Now <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@ -424,7 +438,7 @@ export default function MyListingsPage() {
|
||||
label="Active Listings"
|
||||
value={activeCount}
|
||||
subValue="Live on market"
|
||||
icon={Store}
|
||||
icon={Globe}
|
||||
trend="active"
|
||||
/>
|
||||
<StatCard
|
||||
|
||||
@ -32,10 +32,16 @@ import {
|
||||
Lock,
|
||||
TrendingDown,
|
||||
Zap,
|
||||
Diamond
|
||||
Diamond,
|
||||
Tag,
|
||||
Crown,
|
||||
DollarSign,
|
||||
Copy,
|
||||
Check
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
// ============================================================================
|
||||
// SHARED COMPONENTS (Matched to Market/Intel/Radar)
|
||||
@ -203,9 +209,17 @@ function getTimeAgo(date: string | null): string {
|
||||
// MAIN PAGE
|
||||
// ============================================================================
|
||||
|
||||
// Listing interface for counting active listings
|
||||
interface Listing {
|
||||
id: number
|
||||
domain: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export default function WatchlistPage() {
|
||||
const { domains, addDomain, deleteDomain, refreshDomain, updateDomain, subscription } = useStore()
|
||||
const { toast, showToast, hideToast } = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
// Main tab state (Watching vs My Portfolio)
|
||||
const [mainTab, setMainTab] = useState<MainTab>('watching')
|
||||
@ -234,6 +248,44 @@ export default function WatchlistPage() {
|
||||
const [loadingHealth, setLoadingHealth] = useState<Record<number, boolean>>({})
|
||||
const [selectedHealthDomainId, setSelectedHealthDomainId] = useState<number | null>(null)
|
||||
|
||||
// Listing state for tier limits
|
||||
const [listings, setListings] = useState<Listing[]>([])
|
||||
const [loadingListings, setLoadingListings] = useState(false)
|
||||
|
||||
// Sell Modal state (Wizard)
|
||||
const [showSellModal, setShowSellModal] = useState(false)
|
||||
const [sellStep, setSellStep] = useState<1 | 2 | 3>(1) // 1: Details, 2: DNS Verify, 3: Published
|
||||
const [sellDomainName, setSellDomainName] = useState('')
|
||||
const [sellForm, setSellForm] = useState({
|
||||
price: '',
|
||||
priceType: 'negotiable',
|
||||
allowOffers: true,
|
||||
title: '',
|
||||
})
|
||||
const [sellLoading, setSellLoading] = useState(false)
|
||||
const [sellListingId, setSellListingId] = useState<number | null>(null)
|
||||
const [sellVerificationInfo, setSellVerificationInfo] = useState<{
|
||||
verification_code: string
|
||||
dns_record_name: string
|
||||
dns_record_value: string
|
||||
} | null>(null)
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||
|
||||
// Tier-based limits (from pounce_pricing.md)
|
||||
const tier = subscription?.tier || 'scout'
|
||||
|
||||
// Watchlist limits: Scout=5, Trader=50, Tycoon=500
|
||||
const watchlistLimits: Record<string, number> = { scout: 5, trader: 50, tycoon: 500 }
|
||||
const maxWatchlist = watchlistLimits[tier] || 5
|
||||
|
||||
// Listing limits: Scout=0, Trader=5, Tycoon=50
|
||||
const listingLimits: Record<string, number> = { scout: 0, trader: 5, tycoon: 50 }
|
||||
const maxListings = listingLimits[tier] || 0
|
||||
const canSell = tier !== 'scout'
|
||||
const isTycoon = tier === 'tycoon'
|
||||
const currentListingCount = listings.length
|
||||
const canCreateListing = canSell && currentListingCount < maxListings
|
||||
|
||||
|
||||
// Memoized stats
|
||||
const stats = useMemo(() => {
|
||||
@ -250,11 +302,11 @@ export default function WatchlistPage() {
|
||||
available: available.length,
|
||||
expiringSoon: expiringSoon.length,
|
||||
critical,
|
||||
limit: subscription?.domain_limit || 5,
|
||||
limit: maxWatchlist,
|
||||
}
|
||||
}, [domains, subscription?.domain_limit, healthReports])
|
||||
}, [domains, maxWatchlist, healthReports])
|
||||
|
||||
const canAddMore = stats.total < stats.limit || stats.limit === -1
|
||||
const canAddMore = stats.total < stats.limit
|
||||
|
||||
// Memoized filtered domains
|
||||
const filteredDomains = useMemo(() => {
|
||||
@ -391,6 +443,116 @@ export default function WatchlistPage() {
|
||||
loadHealthData()
|
||||
}, [domains])
|
||||
|
||||
// Load user's listings to check limits
|
||||
useEffect(() => {
|
||||
const loadListings = async () => {
|
||||
if (!canSell) return // Scout users can't list, no need to load
|
||||
|
||||
setLoadingListings(true)
|
||||
try {
|
||||
const data = await api.request<Listing[]>('/listings/my')
|
||||
setListings(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load listings:', err)
|
||||
} finally {
|
||||
setLoadingListings(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadListings()
|
||||
}, [canSell])
|
||||
|
||||
// Handle "Sell" button click - open wizard modal
|
||||
const handleSellDomain = useCallback((domainName: string) => {
|
||||
if (!canSell) {
|
||||
showToast('Upgrade to Trader or Tycoon to sell domains', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!canCreateListing) {
|
||||
showToast(`Listing limit reached (${currentListingCount}/${maxListings}). Upgrade to list more.`, 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// Open sell wizard modal
|
||||
setSellDomainName(domainName)
|
||||
setSellStep(1)
|
||||
setSellForm({ price: '', priceType: 'negotiable', allowOffers: true, title: '' })
|
||||
setSellListingId(null)
|
||||
setSellVerificationInfo(null)
|
||||
setShowSellModal(true)
|
||||
}, [canSell, canCreateListing, currentListingCount, maxListings, showToast])
|
||||
|
||||
// Step 1: Create listing
|
||||
const handleCreateListing = useCallback(async () => {
|
||||
setSellLoading(true)
|
||||
try {
|
||||
const response = await api.request<{ id: number }>('/listings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
domain: sellDomainName,
|
||||
title: sellForm.title || null,
|
||||
asking_price: sellForm.price ? parseFloat(sellForm.price) : null,
|
||||
price_type: sellForm.priceType,
|
||||
allow_offers: sellForm.allowOffers,
|
||||
}),
|
||||
})
|
||||
setSellListingId(response.id)
|
||||
|
||||
// Start DNS verification
|
||||
const verifyResponse = await api.request<{
|
||||
verification_code: string
|
||||
dns_record_name: string
|
||||
dns_record_value: string
|
||||
}>(`/listings/${response.id}/verify-dns`, { method: 'POST' })
|
||||
|
||||
setSellVerificationInfo(verifyResponse)
|
||||
setSellStep(2)
|
||||
showToast('Listing created! Now verify ownership.', 'success')
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Failed to create listing', 'error')
|
||||
} finally {
|
||||
setSellLoading(false)
|
||||
}
|
||||
}, [sellDomainName, sellForm, showToast])
|
||||
|
||||
// Step 2: Check DNS verification
|
||||
const handleCheckVerification = useCallback(async () => {
|
||||
if (!sellListingId) return
|
||||
setSellLoading(true)
|
||||
try {
|
||||
const result = await api.request<{ verified: boolean; message: string }>(
|
||||
`/listings/${sellListingId}/verify-dns/check`
|
||||
)
|
||||
|
||||
if (result.verified) {
|
||||
// Publish the listing
|
||||
await api.request(`/listings/${sellListingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: 'active' }),
|
||||
})
|
||||
setSellStep(3)
|
||||
showToast('Domain verified and published!', 'success')
|
||||
// Reload listings
|
||||
const data = await api.request<Listing[]>('/listings/my')
|
||||
setListings(data)
|
||||
} else {
|
||||
showToast(result.message || 'Verification pending. Check your DNS settings.', 'error')
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Verification failed', 'error')
|
||||
} finally {
|
||||
setSellLoading(false)
|
||||
}
|
||||
}, [sellListingId, showToast])
|
||||
|
||||
// Copy to clipboard helper
|
||||
const copyToClipboard = useCallback((text: string, field: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopiedField(field)
|
||||
setTimeout(() => setCopiedField(null), 2000)
|
||||
}, [])
|
||||
|
||||
// Portfolio uses the SAME domains as Watchlist - just a different view
|
||||
// This ensures consistent monitoring behavior
|
||||
|
||||
@ -1144,12 +1306,44 @@ export default function WatchlistPage() {
|
||||
}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Link
|
||||
href="/terminal/listing"
|
||||
className="px-3 py-1.5 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-lg text-[11px] font-medium hover:bg-emerald-500/20 transition-colors"
|
||||
|
||||
{/* Sell Button - Tier-based */}
|
||||
{canSell ? (
|
||||
<Tooltip content={
|
||||
!canCreateListing
|
||||
? `Limit reached (${currentListingCount}/${maxListings})`
|
||||
: isTycoon
|
||||
? "List for sale (Featured)"
|
||||
: "List for sale"
|
||||
}>
|
||||
<button
|
||||
onClick={() => handleSellDomain(domain.name)}
|
||||
disabled={!canCreateListing}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg text-[11px] font-medium flex items-center gap-1.5 transition-colors",
|
||||
canCreateListing
|
||||
? "bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/20"
|
||||
: "bg-zinc-800/50 border border-zinc-700 text-zinc-500 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
Sell
|
||||
{isTycoon && canCreateListing && (
|
||||
<Sparkles className="w-2.5 h-2.5 text-amber-400" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip content="Upgrade to Trader to sell domains">
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="px-3 py-1.5 rounded-lg text-[11px] font-medium flex items-center gap-1.5 bg-zinc-800/50 border border-zinc-700 text-zinc-500 hover:text-amber-400 hover:border-amber-500/30 transition-colors"
|
||||
>
|
||||
<Crown className="w-3 h-3" />
|
||||
Upgrade
|
||||
</Link>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -1257,12 +1451,33 @@ export default function WatchlistPage() {
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<Link
|
||||
href="/terminal/listing"
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg text-xs font-bold uppercase tracking-wider"
|
||||
{/* Sell Button - Tier-based (Mobile) */}
|
||||
{canSell ? (
|
||||
<button
|
||||
onClick={() => handleSellDomain(domain.name)}
|
||||
disabled={!canCreateListing}
|
||||
className={clsx(
|
||||
"px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider flex items-center gap-1.5",
|
||||
canCreateListing
|
||||
? "bg-emerald-500 text-white"
|
||||
: "bg-zinc-700 text-zinc-400 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
Sell
|
||||
{isTycoon && canCreateListing && (
|
||||
<Sparkles className="w-2.5 h-2.5 text-amber-300" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="px-4 py-2 bg-amber-500/20 text-amber-400 rounded-lg text-xs font-bold uppercase tracking-wider flex items-center gap-1.5"
|
||||
>
|
||||
<Crown className="w-3 h-3" />
|
||||
Upgrade
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -1272,7 +1487,7 @@ export default function WatchlistPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Portfolio Footer */}
|
||||
{/* Portfolio Footer with Listing Info */}
|
||||
<div className="flex items-center justify-center gap-4 text-[10px] text-zinc-700 py-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
@ -1282,6 +1497,19 @@ export default function WatchlistPage() {
|
||||
<Bell className="w-3 h-3" />
|
||||
Get alerts for changes
|
||||
</span>
|
||||
{canSell && (
|
||||
<span className="flex items-center gap-1 text-zinc-500">
|
||||
<Tag className="w-3 h-3" />
|
||||
{currentListingCount}/{maxListings} listings
|
||||
{isTycoon && <Sparkles className="w-2.5 h-2.5 text-amber-500" />}
|
||||
</span>
|
||||
)}
|
||||
{!canSell && (
|
||||
<Link href="/pricing" className="flex items-center gap-1 text-amber-500/70 hover:text-amber-400 transition-colors">
|
||||
<Crown className="w-3 h-3" />
|
||||
Upgrade to sell
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@ -1294,6 +1522,256 @@ export default function WatchlistPage() {
|
||||
onClose={() => setSelectedHealthDomainId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sell Wizard Modal */}
|
||||
{showSellModal && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={() => setShowSellModal(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg bg-[#0A0A0A] border border-white/10 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 fade-in duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header with Steps */}
|
||||
<div className="p-6 border-b border-white/5 bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg border bg-emerald-500/10 border-emerald-500/20">
|
||||
<Tag className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-white">List for Sale</h3>
|
||||
<p className="text-xs text-zinc-500 font-mono">{sellDomainName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSellModal(false)}
|
||||
className="p-2 hover:bg-white/10 rounded-full text-zinc-500 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{[1, 2, 3].map((step) => (
|
||||
<div key={step} className="flex items-center gap-2 flex-1">
|
||||
<div className={clsx(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold border transition-all",
|
||||
sellStep >= step
|
||||
? "bg-emerald-500 text-black border-emerald-500"
|
||||
: "bg-zinc-800 text-zinc-500 border-zinc-700"
|
||||
)}>
|
||||
{sellStep > step ? <Check className="w-3 h-3" /> : step}
|
||||
</div>
|
||||
<span className={clsx(
|
||||
"text-[10px] uppercase tracking-wider font-medium hidden sm:block",
|
||||
sellStep >= step ? "text-emerald-400" : "text-zinc-600"
|
||||
)}>
|
||||
{step === 1 ? 'Details' : step === 2 ? 'Verify' : 'Done'}
|
||||
</span>
|
||||
{step < 3 && <div className={clsx("flex-1 h-0.5 rounded", sellStep > step ? "bg-emerald-500" : "bg-zinc-800")} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Listing Details */}
|
||||
{sellStep === 1 && (
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Headline (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sellForm.title}
|
||||
onChange={(e) => setSellForm({ ...sellForm, title: e.target.value })}
|
||||
placeholder="e.g. Perfect for AI Startups"
|
||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Price (USD)</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<input
|
||||
type="number"
|
||||
value={sellForm.price}
|
||||
onChange={(e) => setSellForm({ ...sellForm, price: e.target.value })}
|
||||
placeholder="Make Offer"
|
||||
className="w-full pl-9 pr-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">Type</label>
|
||||
<select
|
||||
value={sellForm.priceType}
|
||||
onChange={(e) => setSellForm({ ...sellForm, priceType: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-emerald-500/50 transition-all appearance-none"
|
||||
>
|
||||
<option value="negotiable">Negotiable</option>
|
||||
<option value="fixed">Fixed Price</option>
|
||||
<option value="make_offer">Make Offer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer p-3 rounded-lg border border-white/5 hover:bg-white/5 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sellForm.allowOffers}
|
||||
onChange={(e) => setSellForm({ ...sellForm, allowOffers: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-white/20 bg-black text-emerald-500 focus:ring-emerald-500 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-zinc-300">Allow buyers to submit offers</span>
|
||||
</label>
|
||||
|
||||
{/* Limits Info */}
|
||||
<div className="p-3 bg-zinc-900/50 border border-white/5 rounded-lg flex items-center justify-between text-xs">
|
||||
<span className="text-zinc-500">Your listing slots:</span>
|
||||
<span className="text-white font-bold">{currentListingCount}/{maxListings}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setShowSellModal(false)}
|
||||
className="flex-1 px-4 py-3 border border-white/10 text-zinc-400 rounded-xl hover:bg-white/5 hover:text-white transition-all font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateListing}
|
||||
disabled={sellLoading}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-emerald-500 text-white font-bold rounded-xl hover:bg-emerald-400 transition-all disabled:opacity-50 shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
{sellLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : <ArrowRight className="w-5 h-5" />}
|
||||
{sellLoading ? 'Creating...' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: DNS Verification */}
|
||||
{sellStep === 2 && sellVerificationInfo && (
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="p-4 bg-amber-500/5 border border-amber-500/20 rounded-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-5 h-5 text-amber-400 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-amber-400 mb-1">Verify Ownership</h4>
|
||||
<p className="text-xs text-amber-300/80">
|
||||
Add this TXT record to your domain's DNS to prove ownership.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider mb-2">Type</div>
|
||||
<div className="p-3 bg-white/5 border border-white/10 rounded-lg font-mono text-white text-center">
|
||||
TXT
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider mb-2">Name / Host</div>
|
||||
<div
|
||||
className="p-3 bg-white/5 border border-white/10 rounded-lg font-mono text-sm text-zinc-300 flex justify-between items-center group cursor-pointer hover:bg-white/10 transition-colors"
|
||||
onClick={() => copyToClipboard(sellVerificationInfo.dns_record_name, 'name')}
|
||||
>
|
||||
<span className="truncate">{sellVerificationInfo.dns_record_name}</span>
|
||||
{copiedField === 'name'
|
||||
? <Check className="w-4 h-4 text-emerald-400" />
|
||||
: <Copy className="w-4 h-4 text-zinc-500 group-hover:text-emerald-400" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider mb-2">Value</div>
|
||||
<div
|
||||
className="p-3 bg-white/5 border border-white/10 rounded-lg font-mono text-xs text-zinc-300 break-all flex justify-between items-start gap-4 group cursor-pointer hover:bg-white/10 transition-colors"
|
||||
onClick={() => copyToClipboard(sellVerificationInfo.dns_record_value, 'value')}
|
||||
>
|
||||
{sellVerificationInfo.dns_record_value}
|
||||
{copiedField === 'value'
|
||||
? <Check className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
: <Copy className="w-4 h-4 text-zinc-500 group-hover:text-emerald-400 shrink-0" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-500/5 border border-blue-500/10 rounded-lg">
|
||||
<p className="text-[11px] text-blue-300/80">
|
||||
💡 DNS changes can take up to 24 hours to propagate, but usually work within minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setShowSellModal(false)}
|
||||
className="px-4 py-3 border border-white/10 text-zinc-400 rounded-xl hover:bg-white/5 hover:text-white transition-all font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCheckVerification}
|
||||
disabled={sellLoading}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-emerald-500 text-white font-bold rounded-xl hover:bg-emerald-400 transition-all disabled:opacity-50 shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
{sellLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : <Shield className="w-5 h-5" />}
|
||||
{sellLoading ? 'Checking...' : 'Verify & Publish'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Success */}
|
||||
{sellStep === 3 && (
|
||||
<div className="p-6 text-center space-y-6">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-500/20 flex items-center justify-center mx-auto">
|
||||
<CheckCircle2 className="w-8 h-8 text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xl font-bold text-white mb-2">Domain Listed!</h4>
|
||||
<p className="text-sm text-zinc-400">
|
||||
<span className="font-mono text-emerald-400">{sellDomainName}</span> is now live on the Pounce Marketplace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isTycoon && (
|
||||
<div className="p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg flex items-center justify-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-sm text-amber-400 font-medium">Featured Listing Active</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowSellModal(false)}
|
||||
className="flex-1 px-4 py-3 border border-white/10 text-zinc-400 rounded-xl hover:bg-white/5 hover:text-white transition-all font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<Link
|
||||
href="/terminal/listing"
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-emerald-500 text-white font-bold rounded-xl hover:bg-emerald-400 transition-all shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
View Listings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TerminalLayout>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,572 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { PremiumTable } from '@/components/PremiumTable'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
TrendingUp,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Search,
|
||||
X,
|
||||
Lock,
|
||||
Globe,
|
||||
AlertTriangle,
|
||||
ArrowUpDown,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface TldData {
|
||||
tld: string
|
||||
type: string
|
||||
description: string
|
||||
avg_registration_price: number
|
||||
min_registration_price: number
|
||||
max_registration_price: number
|
||||
min_renewal_price: number
|
||||
avg_renewal_price: number
|
||||
registrar_count: number
|
||||
trend: string
|
||||
price_change_7d: number
|
||||
price_change_1y: number
|
||||
price_change_3y: number
|
||||
risk_level: 'low' | 'medium' | 'high'
|
||||
risk_reason: string
|
||||
popularity_rank?: number
|
||||
}
|
||||
|
||||
interface TrendingTld {
|
||||
tld: string
|
||||
reason: string
|
||||
price_change: number
|
||||
current_price: number
|
||||
}
|
||||
|
||||
interface PaginationData {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
// Sparkline component - matching Command Center exactly
|
||||
function Sparkline({ trend }: { trend: number }) {
|
||||
const isPositive = trend > 0
|
||||
const isNeutral = trend === 0
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<svg width="40" height="16" viewBox="0 0 40 16" className="overflow-visible">
|
||||
{isNeutral ? (
|
||||
<line x1="0" y1="8" x2="40" y2="8" stroke="currentColor" className="text-foreground-muted" strokeWidth="1.5" />
|
||||
) : isPositive ? (
|
||||
<polyline
|
||||
points="0,14 10,12 20,10 30,6 40,2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-orange-400"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
) : (
|
||||
<polyline
|
||||
points="0,2 10,6 20,10 30,12 40,14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-accent"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TldPricingPage() {
|
||||
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
||||
const [tlds, setTlds] = useState<TldData[]>([])
|
||||
const [trending, setTrending] = useState<TrendingTld[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pagination, setPagination] = useState<PaginationData>({ total: 0, limit: 50, offset: 0, has_more: false })
|
||||
|
||||
// Search & Sort state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [sortBy, setSortBy] = useState('popularity')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(searchQuery)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchQuery])
|
||||
/**
|
||||
* Redirect /tld-pricing to /intel
|
||||
* This page is kept for backwards compatibility
|
||||
*/
|
||||
export default function TldPricingRedirect() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
loadTrending()
|
||||
}, [checkAuth])
|
||||
|
||||
// Load TLDs with pagination, search, and sort
|
||||
useEffect(() => {
|
||||
loadTlds()
|
||||
}, [debouncedSearch, sortBy, page])
|
||||
|
||||
const loadTlds = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.getTldOverview(
|
||||
50,
|
||||
page * 50,
|
||||
sortBy as 'popularity' | 'price_asc' | 'price_desc' | 'name',
|
||||
debouncedSearch || undefined
|
||||
)
|
||||
|
||||
setTlds(data?.tlds || [])
|
||||
setPagination({
|
||||
total: data?.total || 0,
|
||||
limit: 50,
|
||||
offset: page * 50,
|
||||
has_more: data?.has_more || false,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load TLD data:', error)
|
||||
setTlds([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTrending = async () => {
|
||||
try {
|
||||
const data = await api.getTrendingTlds()
|
||||
setTrending(data?.trending || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load trending:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Risk badge - matching Command Center exactly
|
||||
const getRiskBadge = (tld: TldData) => {
|
||||
const level = tld.risk_level || 'low'
|
||||
const reason = tld.risk_reason || 'Stable'
|
||||
return (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
level === 'high' && "bg-red-500/10 text-red-400",
|
||||
level === 'medium' && "bg-amber-500/10 text-amber-400",
|
||||
level === 'low' && "bg-accent/10 text-accent"
|
||||
)}>
|
||||
<span className={clsx(
|
||||
"w-2.5 h-2.5 rounded-full",
|
||||
level === 'high' && "bg-red-400",
|
||||
level === 'medium' && "bg-amber-400",
|
||||
level === 'low' && "bg-accent"
|
||||
)} />
|
||||
<span className="hidden sm:inline ml-1">{reason}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Get renewal trap indicator
|
||||
const getRenewalTrap = (tld: TldData) => {
|
||||
if (!tld.min_renewal_price || !tld.min_registration_price) return null
|
||||
const ratio = tld.min_renewal_price / tld.min_registration_price
|
||||
if (ratio > 2) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-amber-400" title={`Renewal is ${ratio.toFixed(1)}x the registration price`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Pagination calculations
|
||||
const currentPage = Math.floor(pagination.offset / pagination.limit) + 1
|
||||
const totalPages = Math.ceil(pagination.total / pagination.limit)
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
router.replace('/intel')
|
||||
}, [router])
|
||||
|
||||
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 className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-foreground-muted">Redirecting to Intel...</p>
|
||||
</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-12 sm:mb-16 animate-fade-in">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 text-accent text-sm mb-6">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>Real-time Market Data</span>
|
||||
</div>
|
||||
<h1 className="font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
|
||||
{pagination.total}+ TLDs.
|
||||
<span className="block text-accent">True Costs.</span>
|
||||
</h1>
|
||||
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
||||
Don't fall for promo prices. See renewal costs, spot traps, and track price trends across every extension.
|
||||
</p>
|
||||
|
||||
{/* Feature Pills */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 mt-8">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-foreground-muted">Renewal Trap Detection</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-accent" />
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<span className="w-2 h-2 rounded-full bg-red-400" />
|
||||
</div>
|
||||
<span className="text-foreground-muted">Risk Levels</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-foreground/5 rounded-full text-sm">
|
||||
<TrendingUp className="w-4 h-4 text-orange-400" />
|
||||
<span className="text-foreground-muted">1y/3y Trends</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Banner for non-authenticated users */}
|
||||
{!isAuthenticated && (
|
||||
<div className="mb-8 p-6 bg-gradient-to-r from-accent/10 to-accent/5 border border-accent/20 rounded-2xl animate-fade-in">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-accent/20 rounded-xl flex items-center justify-center">
|
||||
<Lock className="w-6 h-6 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Stop overpaying. Know the true costs.</p>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
Unlock renewal traps, 1y/3y trends, and risk analysis for {pagination.total}+ TLDs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/register"
|
||||
className="shrink-0 px-6 py-3 bg-accent text-background font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
|
||||
>
|
||||
Start Free
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trending Section */}
|
||||
{trending.length > 0 && (
|
||||
<div className="mb-12 sm:mb-16 animate-slide-up">
|
||||
<h2 className="text-body-lg sm:text-heading-sm font-medium text-foreground mb-4 sm:mb-6 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-accent" />
|
||||
Moving Now
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
{trending.map((item) => (
|
||||
<Link
|
||||
key={item.tld}
|
||||
href={isAuthenticated ? `/tld-pricing/${item.tld}` : '/register'}
|
||||
className="p-4 sm:p-5 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover hover:bg-background-secondary transition-all duration-300 text-left group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-mono text-body-lg sm:text-heading-sm text-foreground">.{item.tld}</span>
|
||||
<span className={clsx(
|
||||
"text-ui-sm font-medium px-2 py-0.5 rounded-full",
|
||||
item.price_change > 0
|
||||
? "text-[#f97316] bg-[#f9731615]"
|
||||
: "text-accent bg-accent-muted"
|
||||
)}>
|
||||
{item.price_change > 0 ? '+' : ''}{item.price_change.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-body-sm text-foreground-muted mb-2 line-clamp-2">
|
||||
{item.reason}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-body-sm text-foreground-subtle">
|
||||
${item.current_price.toFixed(2)}/yr
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search & Sort Controls */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6 animate-slide-up">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 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 TLDs (e.g., com, io, ai)..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
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('')
|
||||
setPage(0)
|
||||
}}
|
||||
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>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
className="appearance-none pl-4 pr-10 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
|
||||
transition-all cursor-pointer min-w-[180px]"
|
||||
>
|
||||
<option value="popularity">Most Popular</option>
|
||||
<option value="name">Alphabetical</option>
|
||||
<option value="price_asc">Price: Low → High</option>
|
||||
<option value="price_desc">Price: High → Low</option>
|
||||
</select>
|
||||
<ArrowUpDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TLD Table using PremiumTable - matching Command Center exactly */}
|
||||
<PremiumTable
|
||||
data={tlds}
|
||||
keyExtractor={(tld) => tld.tld}
|
||||
loading={loading}
|
||||
onRowClick={(tld) => {
|
||||
if (isAuthenticated) {
|
||||
window.location.href = `/tld-pricing/${tld.tld}`
|
||||
} else {
|
||||
window.location.href = `/login?redirect=/tld-pricing/${tld.tld}`
|
||||
}
|
||||
}}
|
||||
emptyIcon={<Globe className="w-12 h-12 text-foreground-subtle" />}
|
||||
emptyTitle="No TLDs found"
|
||||
emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"}
|
||||
columns={[
|
||||
{
|
||||
key: 'tld',
|
||||
header: 'TLD',
|
||||
width: '100px',
|
||||
render: (tld, idx) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-lg font-semibold text-foreground group-hover:text-accent transition-colors">
|
||||
.{tld.tld}
|
||||
</span>
|
||||
{!isAuthenticated && idx === 0 && page === 0 && (
|
||||
<span className="text-xs text-accent">Preview</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'trend',
|
||||
header: 'Trend',
|
||||
width: '80px',
|
||||
hideOnMobile: true,
|
||||
render: (tld, idx) => {
|
||||
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||
if (!showData) {
|
||||
return <div className="w-10 h-4 bg-foreground/5 rounded blur-[3px]" />
|
||||
}
|
||||
return <Sparkline trend={tld.price_change_1y || 0} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'buy_price',
|
||||
header: 'Buy (1y)',
|
||||
align: 'right',
|
||||
width: '100px',
|
||||
render: (tld, idx) => {
|
||||
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||
if (!showData) {
|
||||
return <span className="text-foreground-subtle">•••</span>
|
||||
}
|
||||
return <span className="font-semibold text-foreground tabular-nums">${tld.min_registration_price.toFixed(2)}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'renew_price',
|
||||
header: 'Renew (1y)',
|
||||
align: 'right',
|
||||
width: '120px',
|
||||
render: (tld, idx) => {
|
||||
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||
if (!showData) {
|
||||
return <span className="text-foreground-subtle blur-[3px]">$XX.XX</span>
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<span className="text-foreground-muted tabular-nums">
|
||||
${tld.min_renewal_price?.toFixed(2) || '—'}
|
||||
</span>
|
||||
{getRenewalTrap(tld)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'change_1y',
|
||||
header: '1y Change',
|
||||
align: 'right',
|
||||
width: '100px',
|
||||
hideOnMobile: true,
|
||||
render: (tld, idx) => {
|
||||
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||
if (!showData) {
|
||||
return <span className="text-foreground-subtle blur-[3px]">+X%</span>
|
||||
}
|
||||
const change = tld.price_change_1y || 0
|
||||
return (
|
||||
<span className={clsx(
|
||||
"font-medium tabular-nums",
|
||||
change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted"
|
||||
)}>
|
||||
{change > 0 ? '+' : ''}{change.toFixed(0)}%
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'change_3y',
|
||||
header: '3y Change',
|
||||
align: 'right',
|
||||
width: '100px',
|
||||
hideOnMobile: true,
|
||||
render: (tld, idx) => {
|
||||
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||
if (!showData) {
|
||||
return <span className="text-foreground-subtle blur-[3px]">+X%</span>
|
||||
}
|
||||
const change = tld.price_change_3y || 0
|
||||
return (
|
||||
<span className={clsx(
|
||||
"font-medium tabular-nums",
|
||||
change > 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted"
|
||||
)}>
|
||||
{change > 0 ? '+' : ''}{change.toFixed(0)}%
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'risk',
|
||||
header: 'Risk',
|
||||
align: 'center',
|
||||
width: '130px',
|
||||
render: (tld, idx) => {
|
||||
const showData = isAuthenticated || (page === 0 && idx === 0)
|
||||
if (!showData) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-foreground/5 blur-[3px]">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-foreground-subtle" />
|
||||
<span className="hidden sm:inline ml-1">Hidden</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return getRiskBadge(tld)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right',
|
||||
width: '80px',
|
||||
render: () => (
|
||||
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && pagination.total > pagination.limit && (
|
||||
<div className="flex items-center justify-center gap-4 pt-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-foreground-muted tabular-nums">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={!pagination.has_more}
|
||||
className="px-4 py-2 text-sm font-medium text-foreground-muted hover:text-foreground
|
||||
bg-foreground/5 hover:bg-foreground/10 rounded-lg
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{!loading && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<p className="text-ui-sm text-foreground-subtle">
|
||||
{searchQuery
|
||||
? `Found ${pagination.total} TLDs matching "${searchQuery}"`
|
||||
: `${pagination.total} TLDs available`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@ import {
|
||||
Gavel,
|
||||
CreditCard,
|
||||
LayoutDashboard,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@ -38,10 +37,10 @@ export function Header() {
|
||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||
|
||||
// Public navigation - same for all visitors
|
||||
// Navigation: Market | Intel | Pricing (gemäß pounce_public.md)
|
||||
const publicNavItems = [
|
||||
{ href: '/auctions', label: 'Auctions', icon: Gavel },
|
||||
{ href: '/buy', label: 'Marketplace', icon: Tag },
|
||||
{ href: '/tld-pricing', label: 'TLD Pricing', icon: TrendingUp },
|
||||
{ href: '/market', label: 'Market', icon: Gavel },
|
||||
{ href: '/intel', label: 'Intel', icon: TrendingUp },
|
||||
{ href: '/pricing', label: 'Pricing', icon: CreditCard },
|
||||
]
|
||||
|
||||
@ -120,10 +119,10 @@ export function Header() {
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex items-center h-9 ml-1 px-5 text-[0.8125rem] bg-foreground text-background rounded-lg
|
||||
font-medium hover:bg-foreground/90 transition-all duration-200"
|
||||
className="flex items-center h-9 ml-1 px-5 text-[0.8125rem] bg-accent text-background rounded-lg
|
||||
font-medium hover:bg-accent-hover transition-all duration-200 shadow-[0_0_20px_rgba(16,185,129,0.2)]"
|
||||
>
|
||||
Get Started
|
||||
Start Hunting
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
@ -183,10 +182,10 @@ export function Header() {
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="block px-4 py-3 text-body-sm text-center bg-foreground text-background
|
||||
rounded-xl font-medium hover:bg-foreground/90 transition-all duration-200"
|
||||
className="block px-4 py-3 text-body-sm text-center bg-accent text-background
|
||||
rounded-xl font-medium hover:bg-accent-hover transition-all duration-200 shadow-[0_0_20px_rgba(16,185,129,0.2)]"
|
||||
>
|
||||
Get Started
|
||||
Start Hunting
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user