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
679 lines
25 KiB
TypeScript
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'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>
|
|
)
|
|
}
|