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

- 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:
yves.gugger
2025-12-12 10:29:47 +01:00
parent 6511f39609
commit fffbc4747a
11 changed files with 3147 additions and 2546 deletions

View File

@ -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])
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>
)
}
router.replace('/market')
}, [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 Market...</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">
{/* 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>
)
}

File diff suppressed because it is too large Load Diff

View 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&apos;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>
)
}

View File

@ -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">
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)
}
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 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 &apos;Trader&apos; 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>
)
}

View File

@ -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` }}

View File

@ -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 = [

View File

@ -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,21 +356,13 @@ 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}
className="px-4 py-2 bg-emerald-500 text-white font-medium rounded-lg hover:bg-emerald-400 transition-all shadow-lg shadow-emerald-500/20 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Plus className="w-4 h-4" /> New Listing
</button>
</div>
<button
onClick={() => setShowCreateModal(true)}
disabled={listings.length >= maxListings}
className="px-4 py-2 bg-emerald-500 text-white font-medium rounded-lg hover:bg-emerald-400 transition-all shadow-lg shadow-emerald-500/20 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Plus className="w-4 h-4" /> New Listing
</button>
</div>
{/* Messages */}
@ -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

View File

@ -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
</Link>
{/* 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
</Link>
{/* 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

View File

@ -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
}
/**
* Redirect /tld-pricing to /intel
* This page is kept for backwards compatibility
*/
export default function TldPricingRedirect() {
const router = useRouter()
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
useEffect(() => {
router.replace('/intel')
}, [router])
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])
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>
)
}
return (
<div className="min-h-screen bg-background relative overflow-hidden">
{/* Background Effects - matching landing page */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
<div
className="absolute inset-0 opacity-[0.015]"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
backgroundSize: '64px 64px',
}}
/>
</div>
<Header />
<main className="relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 flex-1">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="text-center mb-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&apos;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 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>
</div>
)
}

View File

@ -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>
</>
)}