yves.gugger 2e8bd4a440 refactor: Remove emojis + Add Portfolio to Landing Page
EMOJI REMOVAL:
- Replaced 🟢🟡🔴 emojis with CSS circles (bg-accent, bg-amber-400, bg-red-400)
- Updated TLD Pricing pages (public + command)
- Updated Landing Page feature pills
- Updated Admin panel feature checklist

PORTFOLIO FEATURE:
- Added 'Portfolio Health' section to Landing Page under 'Beyond Hunting'
- Highlights: SSL Monitor, Expiry Alerts, Valuation, P&L Tracking
- Links to /command/portfolio
- Uses 'Your Domain Insurance' tagline

Portfolio Status:
- Public Page: N/A (personal feature, no public page needed)
- Command Center:  Fully implemented with Add/Edit/Sell/Valuation
- Admin Panel:  Stats visible in Overview
- Landing Page:  Now advertised in 'Beyond Hunting' section
2025-12-10 14:51:49 +01:00

679 lines
25 KiB
TypeScript

'use client'
import { useEffect, useState, useMemo } from 'react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import { PremiumTable, Badge, PlatformBadge, StatCard, PageContainer } from '@/components/PremiumTable'
import {
Clock,
ExternalLink,
Search,
Flame,
Timer,
Gavel,
ChevronUp,
ChevronDown,
ChevronsUpDown,
DollarSign,
RefreshCw,
Target,
X,
TrendingUp,
Loader2,
Sparkles,
Eye,
Filter,
Zap,
Crown,
Plus,
Check,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
interface Auction {
domain: string
platform: string
platform_url: string
current_bid: number
currency: string
num_bids: number
end_time: string
time_remaining: string
buy_now_price: number | null
reserve_met: boolean | null
traffic: number | null
age_years: number | null
tld: string
affiliate_url: string
}
interface Opportunity {
auction: Auction
analysis: {
opportunity_score: number
urgency?: string
competition?: string
price_range?: string
recommendation: string
reasoning?: string
}
}
type TabType = 'all' | 'ending' | 'hot' | 'opportunities'
type SortField = 'ending' | 'bid_asc' | 'bid_desc' | 'bids' | 'score'
type FilterPreset = 'all' | 'no-trash' | 'short' | 'high-value' | 'low-competition'
const PLATFORMS = [
{ id: 'All', name: 'All Sources' },
{ id: 'GoDaddy', name: 'GoDaddy' },
{ id: 'Sedo', name: 'Sedo' },
{ id: 'NameJet', name: 'NameJet' },
{ 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() {
const { isAuthenticated, subscription } = useStore()
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
const [endingSoon, setEndingSoon] = useState<Auction[]>([])
const [hotAuctions, setHotAuctions] = useState<Auction[]>([])
const [opportunities, setOpportunities] = useState<Opportunity[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [activeTab, setActiveTab] = useState<TabType>('all')
const [sortBy, setSortBy] = useState<SortField>('ending')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
// Filters
const [searchQuery, setSearchQuery] = useState('')
const [selectedPlatform, setSelectedPlatform] = useState('All')
const [maxBid, setMaxBid] = useState<string>('')
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(() => {
loadData()
}, [])
useEffect(() => {
if (isAuthenticated && opportunities.length === 0) {
loadOpportunities()
}
}, [isAuthenticated])
const loadOpportunities = async () => {
try {
const oppData = await api.getAuctionOpportunities()
setOpportunities(oppData.opportunities || [])
} catch (e) {
console.error('Failed to load opportunities:', e)
}
}
const loadData = async () => {
setLoading(true)
try {
const [auctionsData, hotData, endingData] = await Promise.all([
api.getAuctions(),
api.getHotAuctions(50),
api.getEndingSoonAuctions(24, 50),
])
setAllAuctions(auctionsData.auctions || [])
setHotAuctions(hotData || [])
setEndingSoon(endingData || [])
if (isAuthenticated) {
await loadOpportunities()
}
} catch (error) {
console.error('Failed to load auction data:', error)
} finally {
setLoading(false)
}
}
const handleRefresh = async () => {
setRefreshing(true)
await loadData()
setRefreshing(false)
}
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value)
}
const getCurrentAuctions = (): Auction[] => {
switch (activeTab) {
case 'ending': return endingSoon
case 'hot': return hotAuctions
case 'opportunities': return opportunities.map(o => o.auction)
default: return allAuctions
}
}
const getOpportunityData = (domain: string) => {
if (activeTab !== 'opportunities') return null
return opportunities.find(o => o.auction.domain === domain)?.analysis
}
// Track domain to watchlist
const handleTrackDomain = async (domain: string) => {
if (trackedDomains.has(domain)) return
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'
? filteredAuctions
: [...filteredAuctions].sort((a, b) => {
const mult = sortDirection === 'asc' ? 1 : -1
switch (sortBy) {
case 'ending':
return mult * (new Date(a.end_time).getTime() - new Date(b.end_time).getTime())
case 'bid_asc':
case 'bid_desc':
return mult * (a.current_bid - b.current_bid)
case 'bids':
return mult * (b.num_bids - a.num_bids)
case 'score':
return mult * (calculateDealScore(b) - calculateDealScore(a))
default:
return 0
}
})
const getTimeColor = (timeRemaining: string) => {
if (timeRemaining.includes('m') && !timeRemaining.includes('h') && !timeRemaining.includes('d')) return 'text-red-400'
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 2) return 'text-amber-400'
return 'text-foreground-muted'
}
const handleSort = (field: SortField) => {
if (sortBy === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortBy(field)
setSortDirection('asc')
}
}
// Dynamic subtitle
const getSubtitle = () => {
if (loading) return 'Loading live auctions...'
const total = allAuctions.length
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 (
<CommandCenterLayout
title="Auctions"
subtitle={getSubtitle()}
actions={
<button
onClick={handleRefresh}
disabled={refreshing}
className="flex items-center gap-2 px-4 py-2 text-sm text-foreground-muted hover:text-foreground
hover:bg-foreground/5 rounded-lg transition-all disabled:opacity-50"
>
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
<span className="hidden sm:inline">Refresh</span>
</button>
}
>
<PageContainer>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="All Auctions" value={allAuctions.length} icon={Gavel} />
<StatCard title="Ending Soon" value={endingSoon.length} icon={Timer} accent />
<StatCard title="Hot Auctions" value={hotAuctions.length} subtitle="20+ bids" icon={Flame} />
<StatCard title="Opportunities" value={opportunities.length} icon={Target} />
</div>
{/* Tabs */}
<div className="flex flex-wrap items-center gap-2 p-1.5 bg-background-secondary/30 border border-border/30 rounded-2xl w-fit">
{[
{ id: 'all' as const, label: 'All', icon: Gavel, count: allAuctions.length },
{ id: 'ending' as const, label: 'Ending Soon', icon: Timer, count: endingSoon.length, color: 'warning' },
{ id: 'hot' as const, label: 'Hot', icon: Flame, count: hotAuctions.length },
{ id: 'opportunities' as const, label: 'Opportunities', icon: Target, count: opportunities.length },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-xl transition-all",
activeTab === tab.id
? tab.color === 'warning'
? "bg-amber-500 text-background"
: "bg-gradient-to-r from-accent to-accent/80 text-background shadow-lg shadow-accent/20"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)}
>
<tab.icon className="w-4 h-4" />
<span className="hidden sm:inline">{tab.label}</span>
<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>
{/* 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&apos;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 */}
<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-4 h-4 text-foreground-subtle" />
<input
type="text"
placeholder="Search domains..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-11 pr-10 py-3 bg-background-secondary/50 border border-border/50 rounded-xl
text-sm text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:border-accent/50 transition-all"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-4 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground">
<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/50 rounded-xl
text-sm text-foreground cursor-pointer focus:outline-none focus:border-accent/50"
>
{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/50 rounded-xl
text-sm text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:border-accent/50"
/>
</div>
</div>
{/* Table */}
<PremiumTable
data={sortedAuctions}
keyExtractor={(a) => `${a.domain}-${a.platform}`}
loading={loading}
sortBy={sortBy}
sortDirection={sortDirection}
onSort={(key) => handleSort(key as SortField)}
emptyIcon={<Gavel className="w-12 h-12 text-foreground-subtle" />}
emptyTitle={searchQuery ? `No auctions matching "${searchQuery}"` : "No auctions found"}
emptyDescription="Try adjusting your filters or check back later"
columns={[
{
key: 'domain',
header: 'Domain',
sortable: true,
render: (a) => (
<div>
<a
href={a.affiliate_url}
target="_blank"
rel="noopener noreferrer"
className="font-mono font-medium text-foreground hover:text-accent transition-colors"
>
{a.domain}
</a>
<div className="flex items-center gap-2 mt-1 lg:hidden">
<PlatformBadge platform={a.platform} />
{a.age_years && <span className="text-xs text-foreground-subtle">{a.age_years}y</span>}
</div>
</div>
),
},
{
key: 'platform',
header: 'Platform',
hideOnMobile: true,
render: (a) => (
<div className="space-y-1">
<PlatformBadge platform={a.platform} />
{a.age_years && (
<span className="text-xs text-foreground-subtle flex items-center gap-1">
<Clock className="w-3 h-3" /> {a.age_years}y
</span>
)}
</div>
),
},
{
key: 'bid_asc',
header: 'Bid',
sortable: true,
align: 'right',
render: (a) => (
<div>
<span className="font-medium text-foreground">{formatCurrency(a.current_bid)}</span>
{a.buy_now_price && (
<p className="text-xs text-accent">Buy: {formatCurrency(a.buy_now_price)}</p>
)}
</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',
header: 'Bids',
sortable: true,
align: 'right',
hideOnMobile: true,
render: (a) => (
<span className={clsx(
"font-medium flex items-center justify-end gap-1",
a.num_bids >= 20 ? "text-accent" : a.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted"
)}>
{a.num_bids}
{a.num_bids >= 20 && <Flame className="w-3 h-3" />}
</span>
),
},
{
key: 'ending',
header: 'Time Left',
sortable: true,
align: 'right',
hideOnMobile: true,
render: (a) => (
<span className={clsx("font-medium", getTimeColor(a.time_remaining))}>
{a.time_remaining}
</span>
),
},
{
key: 'actions',
header: '',
align: 'right',
render: (a) => (
<div className="flex items-center gap-2 justify-end">
{/* Track Button */}
<button
onClick={(e) => {
e.preventDefault()
handleTrackDomain(a.domain)
}}
disabled={trackedDomains.has(a.domain) || trackingInProgress === a.domain}
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>
),
},
]}
/>
</PageContainer>
</CommandCenterLayout>
)
}