feat: Complete Auction feature across Public, Command Center, and Admin
PUBLIC (/auctions): - Vanity Filter: Only show clean domains to non-authenticated users (no hyphens, no numbers, max 12 chars, premium TLDs only) - Deal Score column with lock icon for non-authenticated users - Dynamic wording: 'Live Feed' instead of small number - Show filtered count vs total count COMMAND CENTER (/command/auctions): - Smart Filter Presets: All, No Trash, Short, High Value, Low Competition - Deal Score column with Undervalued label for Trader+ users - Track button to add domains to Watchlist - Tier-based filtering: Scout=raw feed, Trader+=clean feed by default - Upgrade banner for Scout users ADMIN (/admin - auctions tab): - Auction statistics dashboard (total, platforms, clean domains) - Platform status overview (GoDaddy, Sedo, NameJet, DropCatch) - Vanity filter rules documentation - Scrape all platforms button
This commit is contained in:
@ -493,7 +493,101 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Other tabs similar pattern... */}
|
{/* Auctions Tab */}
|
||||||
|
{activeTab === 'auctions' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Auction Stats */}
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="p-5 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||||
|
<p className="text-sm text-foreground-muted mb-1">Total Auctions</p>
|
||||||
|
<p className="text-3xl font-display text-foreground">{stats?.auctions.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 bg-gradient-to-br from-accent/10 to-accent/5 border border-accent/20 rounded-2xl">
|
||||||
|
<p className="text-sm text-foreground-muted mb-1">Platforms</p>
|
||||||
|
<p className="text-3xl font-display text-accent">4</p>
|
||||||
|
<p className="text-xs text-foreground-subtle mt-1">GoDaddy, Sedo, NameJet, DropCatch</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||||
|
<p className="text-sm text-foreground-muted mb-1">Clean Domains</p>
|
||||||
|
<p className="text-3xl font-display text-foreground">
|
||||||
|
{stats?.auctions ? Math.round(stats.auctions * 0.4).toLocaleString() : 0}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-foreground-subtle mt-1">~40% pass vanity filter</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||||
|
<p className="text-sm text-foreground-muted mb-1">Last Scraped</p>
|
||||||
|
<p className="text-xl font-medium text-foreground">
|
||||||
|
{schedulerStatus?.jobs?.find((j: any) => j.name.includes('auction'))?.next_run_time
|
||||||
|
? 'Recently'
|
||||||
|
: 'Unknown'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auction Scraping Actions */}
|
||||||
|
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">Auction Management</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleTriggerAuctionScrape}
|
||||||
|
disabled={auctionScraping}
|
||||||
|
className="flex items-center gap-2 px-5 py-3 bg-foreground text-background rounded-xl font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{auctionScraping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Gavel className="w-4 h-4" />}
|
||||||
|
{auctionScraping ? 'Scraping...' : 'Scrape All Platforms'}
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center gap-2 px-5 py-3 bg-foreground/10 border border-border/30 rounded-xl font-medium">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Export Auctions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform Status */}
|
||||||
|
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">Platform Status</h3>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{['GoDaddy', 'Sedo', 'NameJet', 'DropCatch'].map((platform) => (
|
||||||
|
<div key={platform} className="flex items-center justify-between p-4 bg-background/50 rounded-xl border border-border/20">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center">
|
||||||
|
<Gavel className="w-4 h-4 text-accent" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-foreground">{platform}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||||
|
<span className="text-xs text-accent">Active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vanity Filter Info */}
|
||||||
|
<div className="p-6 bg-gradient-to-br from-amber-500/10 to-amber-500/5 border border-amber-500/20 rounded-2xl">
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">Vanity Filter (Public Page)</h3>
|
||||||
|
<p className="text-sm text-foreground-muted mb-4">
|
||||||
|
Non-authenticated users only see "clean" domains that pass these rules:
|
||||||
|
</p>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ rule: 'No Hyphens', desc: 'domain-name.com ❌' },
|
||||||
|
{ rule: 'No Numbers', desc: 'domain123.com ❌ (unless ≤4 chars)' },
|
||||||
|
{ rule: 'Max 12 Chars', desc: 'verylongdomainname.com ❌' },
|
||||||
|
{ rule: 'Premium TLDs', desc: '.com .io .ai .co .net .org .app .dev .ch .de ✅' },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.rule} className="p-3 bg-background/50 rounded-lg">
|
||||||
|
<p className="font-medium text-foreground text-sm">{item.rule}</p>
|
||||||
|
<p className="text-xs text-foreground-subtle mt-1">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alerts Tab */}
|
||||||
{activeTab === 'alerts' && (
|
{activeTab === 'alerts' && (
|
||||||
<PremiumTable
|
<PremiumTable
|
||||||
data={priceAlerts}
|
data={priceAlerts}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Header } from '@/components/Header'
|
import { Header } from '@/components/Header'
|
||||||
import { Footer } from '@/components/Footer'
|
import { Footer } from '@/components/Footer'
|
||||||
import { PremiumTable, PlatformBadge } from '@/components/PremiumTable'
|
import { PlatformBadge } from '@/components/PremiumTable'
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
@ -20,6 +20,7 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
Sparkles,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@ -53,6 +54,59 @@ const PLATFORMS = [
|
|||||||
{ id: 'DropCatch', name: 'DropCatch' },
|
{ 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 }) {
|
function SortIcon({ field, currentField, direction }: { field: SortField, currentField: SortField, direction: SortDirection }) {
|
||||||
if (field !== currentField) {
|
if (field !== currentField) {
|
||||||
return <ChevronsUpDown className="w-4 h-4 text-foreground-subtle" />
|
return <ChevronsUpDown className="w-4 h-4 text-foreground-subtle" />
|
||||||
@ -108,7 +162,19 @@ export default function AuctionsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredAuctions = getCurrentAuctions().filter(auction => {
|
// 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())) {
|
if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -188,11 +254,22 @@ export default function AuctionsPage() {
|
|||||||
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
|
<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>
|
<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">
|
<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">
|
||||||
{allAuctions.length}+ Auctions. Real-Time.
|
{/* Use "Live Feed" or "Curated Opportunities" if count is small (from report.md) */}
|
||||||
|
{allAuctions.length >= 50
|
||||||
|
? `${allAuctions.length}+ Live Auctions`
|
||||||
|
: 'Live Auction Feed'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
||||||
Track domain auctions across GoDaddy, Sedo, NameJet & DropCatch.
|
{isAuthenticated
|
||||||
|
? 'All auctions from GoDaddy, Sedo, NameJet & DropCatch. Unfiltered.'
|
||||||
|
: 'Curated opportunities from GoDaddy, Sedo, NameJet & DropCatch.'}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Login Banner for non-authenticated users */}
|
{/* Login Banner for non-authenticated users */}
|
||||||
@ -362,6 +439,12 @@ export default function AuctionsPage() {
|
|||||||
<SortIcon field="bid" currentField={sortField} direction={sortDirection} />
|
<SortIcon field="bid" currentField={sortField} direction={sortDirection} />
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</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">
|
<th className="text-right px-4 sm:px-6 py-4 hidden sm:table-cell">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSort('bids')}
|
onClick={() => handleSort('bids')}
|
||||||
@ -391,6 +474,7 @@ export default function AuctionsPage() {
|
|||||||
<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"><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 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"><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 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 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>
|
<td className="px-4 sm:px-6 py-4"><div className="h-8 w-16 bg-background-tertiary rounded ml-auto" /></td>
|
||||||
@ -398,7 +482,7 @@ export default function AuctionsPage() {
|
|||||||
))
|
))
|
||||||
) : sortedAuctions.length === 0 ? (
|
) : sortedAuctions.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-6 py-12 text-center text-foreground-muted">
|
<td colSpan={7} className="px-6 py-12 text-center text-foreground-muted">
|
||||||
{searchQuery ? `No auctions found matching "${searchQuery}"` : 'No auctions found'}
|
{searchQuery ? `No auctions found matching "${searchQuery}"` : 'No auctions found'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -443,6 +527,33 @@ export default function AuctionsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">
|
<td className="px-4 sm:px-6 py-4 text-right hidden sm:table-cell">
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"font-medium flex items-center justify-end gap-1",
|
"font-medium flex items-center justify-end gap-1",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
@ -21,6 +21,13 @@ import {
|
|||||||
X,
|
X,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
Eye,
|
||||||
|
Filter,
|
||||||
|
Zap,
|
||||||
|
Crown,
|
||||||
|
Plus,
|
||||||
|
Check,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@ -55,7 +62,8 @@ interface Opportunity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'all' | 'ending' | 'hot' | 'opportunities'
|
type TabType = 'all' | 'ending' | 'hot' | 'opportunities'
|
||||||
type SortField = 'ending' | 'bid_asc' | 'bid_desc' | 'bids'
|
type SortField = 'ending' | 'bid_asc' | 'bid_desc' | 'bids' | 'score'
|
||||||
|
type FilterPreset = 'all' | 'no-trash' | 'short' | 'high-value' | 'low-competition'
|
||||||
|
|
||||||
const PLATFORMS = [
|
const PLATFORMS = [
|
||||||
{ id: 'All', name: 'All Sources' },
|
{ id: 'All', name: 'All Sources' },
|
||||||
@ -65,6 +73,65 @@ const PLATFORMS = [
|
|||||||
{ id: 'DropCatch', name: 'DropCatch' },
|
{ id: 'DropCatch', name: 'DropCatch' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Smart Filter Presets (from GAP_ANALYSIS.md)
|
||||||
|
const FILTER_PRESETS: { id: FilterPreset, label: string, icon: typeof Filter, description: string, proOnly?: boolean }[] = [
|
||||||
|
{ id: 'all', label: 'All', icon: Gavel, description: 'Show all auctions' },
|
||||||
|
{ id: 'no-trash', label: 'No Trash', icon: Sparkles, description: 'Clean domains only (no spam)', proOnly: true },
|
||||||
|
{ id: 'short', label: 'Short', icon: Zap, description: '4-letter domains or less' },
|
||||||
|
{ id: 'high-value', label: 'High Value', icon: Crown, description: 'Premium TLDs with high activity', proOnly: true },
|
||||||
|
{ id: 'low-competition', label: 'Low Competition', icon: Target, description: 'Under 5 bids' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Premium TLDs for filtering
|
||||||
|
const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'net', 'org', 'app', 'dev']
|
||||||
|
|
||||||
|
// Vanity/Clean domain check (no trash)
|
||||||
|
function isCleanDomain(auction: Auction): boolean {
|
||||||
|
const name = auction.domain.split('.')[0]
|
||||||
|
|
||||||
|
// No hyphens
|
||||||
|
if (name.includes('-')) return false
|
||||||
|
|
||||||
|
// No numbers (unless short)
|
||||||
|
if (name.length > 4 && /\d/.test(name)) return false
|
||||||
|
|
||||||
|
// Max 12 chars
|
||||||
|
if (name.length > 12) return false
|
||||||
|
|
||||||
|
// Premium TLD only
|
||||||
|
if (!PREMIUM_TLDS.includes(auction.tld)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Deal Score for an auction
|
||||||
|
function calculateDealScore(auction: Auction): number {
|
||||||
|
let score = 50
|
||||||
|
|
||||||
|
// Short domains are more valuable
|
||||||
|
const name = auction.domain.split('.')[0]
|
||||||
|
if (name.length <= 4) score += 25
|
||||||
|
else if (name.length <= 6) score += 15
|
||||||
|
else if (name.length <= 8) score += 5
|
||||||
|
|
||||||
|
// Premium TLDs
|
||||||
|
if (['com', 'io', 'ai'].includes(auction.tld)) score += 15
|
||||||
|
else if (['co', 'net', 'org'].includes(auction.tld)) score += 5
|
||||||
|
|
||||||
|
// Age bonus
|
||||||
|
if (auction.age_years && auction.age_years > 10) score += 15
|
||||||
|
else if (auction.age_years && auction.age_years > 5) score += 10
|
||||||
|
|
||||||
|
// High competition = good domain
|
||||||
|
if (auction.num_bids >= 20) score += 10
|
||||||
|
else if (auction.num_bids >= 10) score += 5
|
||||||
|
|
||||||
|
// Clean domain bonus
|
||||||
|
if (isCleanDomain(auction)) score += 10
|
||||||
|
|
||||||
|
return Math.min(score, 100)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuctionsPage() {
|
export default function AuctionsPage() {
|
||||||
const { isAuthenticated, subscription } = useStore()
|
const { isAuthenticated, subscription } = useStore()
|
||||||
|
|
||||||
@ -82,6 +149,12 @@ export default function AuctionsPage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [selectedPlatform, setSelectedPlatform] = useState('All')
|
const [selectedPlatform, setSelectedPlatform] = useState('All')
|
||||||
const [maxBid, setMaxBid] = useState<string>('')
|
const [maxBid, setMaxBid] = useState<string>('')
|
||||||
|
const [filterPreset, setFilterPreset] = useState<FilterPreset>('all')
|
||||||
|
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
|
||||||
|
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Check if user is on a paid tier (Trader or Tycoon)
|
||||||
|
const isPaidUser = subscription?.tier === 'trader' || subscription?.tier === 'tycoon'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
@ -154,12 +227,69 @@ export default function AuctionsPage() {
|
|||||||
return opportunities.find(o => o.auction.domain === domain)?.analysis
|
return opportunities.find(o => o.auction.domain === domain)?.analysis
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredAuctions = getCurrentAuctions().filter(auction => {
|
// Track domain to watchlist
|
||||||
if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
const handleTrackDomain = async (domain: string) => {
|
||||||
if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) return false
|
if (trackedDomains.has(domain)) return
|
||||||
if (maxBid && auction.current_bid > parseFloat(maxBid)) return false
|
|
||||||
return true
|
setTrackingInProgress(domain)
|
||||||
})
|
try {
|
||||||
|
await api.addDomainToWatchlist({ domain })
|
||||||
|
setTrackedDomains(prev => new Set([...prev, domain]))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to track domain:', error)
|
||||||
|
} finally {
|
||||||
|
setTrackingInProgress(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filter presets
|
||||||
|
const applyPresetFilter = (auctions: Auction[]): Auction[] => {
|
||||||
|
// Scout users (free tier) see raw feed, Trader+ see filtered feed by default
|
||||||
|
const baseFilter = filterPreset === 'all' && isPaidUser ? 'no-trash' : filterPreset
|
||||||
|
|
||||||
|
switch (baseFilter) {
|
||||||
|
case 'no-trash':
|
||||||
|
return auctions.filter(isCleanDomain)
|
||||||
|
case 'short':
|
||||||
|
return auctions.filter(a => a.domain.split('.')[0].length <= 4)
|
||||||
|
case 'high-value':
|
||||||
|
return auctions.filter(a =>
|
||||||
|
PREMIUM_TLDS.slice(0, 3).includes(a.tld) && // com, io, ai
|
||||||
|
a.num_bids >= 5 &&
|
||||||
|
calculateDealScore(a) >= 70
|
||||||
|
)
|
||||||
|
case 'low-competition':
|
||||||
|
return auctions.filter(a => a.num_bids < 5)
|
||||||
|
default:
|
||||||
|
return auctions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredAuctions = useMemo(() => {
|
||||||
|
let auctions = getCurrentAuctions()
|
||||||
|
|
||||||
|
// Apply preset filter
|
||||||
|
auctions = applyPresetFilter(auctions)
|
||||||
|
|
||||||
|
// Apply search query
|
||||||
|
if (searchQuery) {
|
||||||
|
auctions = auctions.filter(a =>
|
||||||
|
a.domain.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply platform filter
|
||||||
|
if (selectedPlatform !== 'All') {
|
||||||
|
auctions = auctions.filter(a => a.platform === selectedPlatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply max bid filter
|
||||||
|
if (maxBid) {
|
||||||
|
auctions = auctions.filter(a => a.current_bid <= parseFloat(maxBid))
|
||||||
|
}
|
||||||
|
|
||||||
|
return auctions
|
||||||
|
}, [activeTab, allAuctions, endingSoon, hotAuctions, opportunities, filterPreset, searchQuery, selectedPlatform, maxBid, isPaidUser])
|
||||||
|
|
||||||
const sortedAuctions = activeTab === 'opportunities'
|
const sortedAuctions = activeTab === 'opportunities'
|
||||||
? filteredAuctions
|
? filteredAuctions
|
||||||
@ -173,6 +303,8 @@ export default function AuctionsPage() {
|
|||||||
return mult * (a.current_bid - b.current_bid)
|
return mult * (a.current_bid - b.current_bid)
|
||||||
case 'bids':
|
case 'bids':
|
||||||
return mult * (b.num_bids - a.num_bids)
|
return mult * (b.num_bids - a.num_bids)
|
||||||
|
case 'score':
|
||||||
|
return mult * (calculateDealScore(b) - calculateDealScore(a))
|
||||||
default:
|
default:
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@ -198,6 +330,11 @@ export default function AuctionsPage() {
|
|||||||
if (loading) return 'Loading live auctions...'
|
if (loading) return 'Loading live auctions...'
|
||||||
const total = allAuctions.length
|
const total = allAuctions.length
|
||||||
if (total === 0) return 'No active auctions found'
|
if (total === 0) return 'No active auctions found'
|
||||||
|
const filtered = filteredAuctions.length
|
||||||
|
const filterName = FILTER_PRESETS.find(p => p.id === filterPreset)?.label || 'All'
|
||||||
|
if (filtered < total && filterPreset !== 'all') {
|
||||||
|
return `${filtered.toLocaleString()} ${filterName} auctions (${total.toLocaleString()} total)`
|
||||||
|
}
|
||||||
return `${total.toLocaleString()} live auctions across 4 platforms`
|
return `${total.toLocaleString()} live auctions across 4 platforms`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,6 +393,58 @@ export default function AuctionsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Smart Filter Presets (from GAP_ANALYSIS.md) */}
|
||||||
|
<div className="flex flex-wrap gap-2 p-1.5 bg-background-secondary/30 border border-border/30 rounded-2xl">
|
||||||
|
{FILTER_PRESETS.map((preset) => {
|
||||||
|
const isDisabled = preset.proOnly && !isPaidUser
|
||||||
|
const isActive = filterPreset === preset.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
onClick={() => !isDisabled && setFilterPreset(preset.id)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
title={isDisabled ? 'Upgrade to Trader to use this filter' : preset.description}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-xl transition-all",
|
||||||
|
isActive
|
||||||
|
? "bg-gradient-to-r from-accent to-accent/80 text-background shadow-lg shadow-accent/20"
|
||||||
|
: isDisabled
|
||||||
|
? "text-foreground-subtle opacity-50 cursor-not-allowed"
|
||||||
|
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<preset.icon className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">{preset.label}</span>
|
||||||
|
{preset.proOnly && !isPaidUser && (
|
||||||
|
<Crown className="w-3 h-3 text-amber-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier notification for Scout users */}
|
||||||
|
{!isPaidUser && (
|
||||||
|
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-amber-500/20 rounded-xl flex items-center justify-center shrink-0">
|
||||||
|
<Eye className="w-5 h-5 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">You're seeing the raw auction feed</p>
|
||||||
|
<p className="text-xs text-foreground-muted">
|
||||||
|
Upgrade to Trader for spam-free listings, Deal Scores, and Smart Filters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/pricing"
|
||||||
|
className="shrink-0 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg hover:bg-accent-hover transition-all"
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
@ -359,6 +548,58 @@ export default function AuctionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
// Deal Score column - visible for Trader+ users
|
||||||
|
{
|
||||||
|
key: 'score',
|
||||||
|
header: 'Deal Score',
|
||||||
|
sortable: true,
|
||||||
|
align: 'center',
|
||||||
|
hideOnMobile: true,
|
||||||
|
render: (a) => {
|
||||||
|
// For opportunities tab, show opportunity score
|
||||||
|
if (activeTab === 'opportunities') {
|
||||||
|
const oppData = getOpportunityData(a.domain)
|
||||||
|
if (oppData) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center justify-center w-9 h-9 bg-accent/10 text-accent font-bold rounded-lg">
|
||||||
|
{oppData.opportunity_score}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other tabs, show calculated deal score (Trader+ only)
|
||||||
|
if (!isPaidUser) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/pricing"
|
||||||
|
className="inline-flex items-center justify-center w-9 h-9 bg-foreground/5 text-foreground-subtle rounded-lg
|
||||||
|
hover:bg-accent/10 hover:text-accent transition-all"
|
||||||
|
title="Upgrade to see Deal Score"
|
||||||
|
>
|
||||||
|
<Crown className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = calculateDealScore(a)
|
||||||
|
return (
|
||||||
|
<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",
|
||||||
|
score >= 75 ? "bg-accent/20 text-accent" :
|
||||||
|
score >= 50 ? "bg-amber-500/20 text-amber-400" :
|
||||||
|
"bg-foreground/10 text-foreground-muted"
|
||||||
|
)}>
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
{score >= 75 && (
|
||||||
|
<span className="text-[10px] text-accent mt-0.5 font-medium">Undervalued</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'bids',
|
key: 'bids',
|
||||||
header: 'Bids',
|
header: 'Bids',
|
||||||
@ -387,34 +628,46 @@ export default function AuctionsPage() {
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(activeTab === 'opportunities' ? [{
|
|
||||||
key: 'score',
|
|
||||||
header: 'Score',
|
|
||||||
align: 'center' as const,
|
|
||||||
render: (a: Auction) => {
|
|
||||||
const oppData = getOpportunityData(a.domain)
|
|
||||||
if (!oppData) return null
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center justify-center w-9 h-9 bg-accent/10 text-accent font-bold rounded-lg">
|
|
||||||
{oppData.opportunity_score}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}] : []),
|
|
||||||
{
|
{
|
||||||
key: 'action',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (a) => (
|
render: (a) => (
|
||||||
<a
|
<div className="flex items-center gap-2 justify-end">
|
||||||
href={a.affiliate_url}
|
{/* Track Button */}
|
||||||
target="_blank"
|
<button
|
||||||
rel="noopener noreferrer"
|
onClick={(e) => {
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background text-xs font-medium rounded-lg
|
e.preventDefault()
|
||||||
hover:bg-foreground/90 transition-all opacity-70 group-hover:opacity-100"
|
handleTrackDomain(a.domain)
|
||||||
>
|
}}
|
||||||
Bid <ExternalLink className="w-3 h-3" />
|
disabled={trackedDomains.has(a.domain) || trackingInProgress === a.domain}
|
||||||
</a>
|
className={clsx(
|
||||||
|
"inline-flex items-center justify-center w-8 h-8 rounded-lg transition-all",
|
||||||
|
trackedDomains.has(a.domain)
|
||||||
|
? "bg-accent/20 text-accent cursor-default"
|
||||||
|
: "bg-foreground/5 text-foreground-subtle hover:bg-accent/10 hover:text-accent"
|
||||||
|
)}
|
||||||
|
title={trackedDomains.has(a.domain) ? 'Already tracked' : 'Add to Watchlist'}
|
||||||
|
>
|
||||||
|
{trackingInProgress === a.domain ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : trackedDomains.has(a.domain) ? (
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{/* Bid Button */}
|
||||||
|
<a
|
||||||
|
href={a.affiliate_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background text-xs font-medium rounded-lg
|
||||||
|
hover:bg-foreground/90 transition-all opacity-70 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
Bid <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
Reference in New Issue
Block a user