feat: MARKET - Final Polish & Mobile Optimization
Changes: - Implemented responsive layout: Desktop Table vs Mobile Cards - Desktop: Glassmorphism header, refined spacing, hover effects - Mobile: Elegant 'Trading Card' layout with optimized touch targets - Visual: New 'ScoreDisplay' component (Ring for Desktop, Badge for Mobile) - UX: Sticky search bar on mobile, better empty states - Polish: Improved typography, consistent borders, micro-interactions
This commit is contained in:
@ -24,7 +24,8 @@ import {
|
||||
Search,
|
||||
LayoutGrid,
|
||||
List,
|
||||
SlidersHorizontal
|
||||
SlidersHorizontal,
|
||||
MoreHorizontal
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@ -77,7 +78,7 @@ function calculatePounceScore(domain: string, tld: string, numBids?: number, age
|
||||
let score = 50
|
||||
const name = domain.split('.')[0]
|
||||
|
||||
// Length bonus (shorter = better)
|
||||
// Length bonus
|
||||
if (name.length <= 3) score += 30
|
||||
else if (name.length === 4) score += 25
|
||||
else if (name.length === 5) score += 20
|
||||
@ -95,12 +96,12 @@ function calculatePounceScore(domain: string, tld: string, numBids?: number, age
|
||||
else if (ageYears && ageYears > 10) score += 7
|
||||
else if (ageYears && ageYears > 5) score += 3
|
||||
|
||||
// Activity bonus (more bids = more valuable)
|
||||
// Activity bonus
|
||||
if (numBids && numBids >= 20) score += 8
|
||||
else if (numBids && numBids >= 10) score += 5
|
||||
else if (numBids && numBids >= 5) score += 2
|
||||
|
||||
// SPAM PENALTIES
|
||||
// Penalties
|
||||
if (name.includes('-')) score -= 25
|
||||
if (/\d/.test(name) && name.length > 3) score -= 20
|
||||
if (name.length > 15) score -= 15
|
||||
@ -118,7 +119,7 @@ function isSpamDomain(domain: string, tld: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse time remaining to seconds for sorting
|
||||
// Parse time remaining to seconds
|
||||
function parseTimeToSeconds(timeStr?: string): number {
|
||||
if (!timeStr) return Infinity
|
||||
let seconds = 0
|
||||
@ -150,52 +151,59 @@ function StatCard({
|
||||
trend?: 'up' | 'down' | 'neutral'
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-zinc-900/40 border border-white/5 rounded-xl p-4 flex items-start justify-between group hover:border-white/10 transition-all">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1">{label}</p>
|
||||
<div className="bg-zinc-900/40 border border-white/5 rounded-xl p-4 flex items-start justify-between hover:bg-white/[0.02] transition-colors relative overflow-hidden group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="relative z-10">
|
||||
<p className="text-[11px] font-semibold text-zinc-500 uppercase tracking-wider mb-1">{label}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-white tracking-tight">{value}</span>
|
||||
{subValue && <span className="text-xs text-zinc-500 font-medium">{subValue}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={clsx(
|
||||
"p-2 rounded-lg bg-zinc-800/50 text-zinc-400 group-hover:text-white transition-colors",
|
||||
"relative z-10 p-2 rounded-lg bg-zinc-800/50 transition-colors",
|
||||
trend === 'up' && "text-emerald-400 bg-emerald-500/10",
|
||||
trend === 'down' && "text-red-400 bg-red-500/10"
|
||||
trend === 'down' && "text-red-400 bg-red-500/10",
|
||||
trend === 'neutral' && "text-zinc-400"
|
||||
)}>
|
||||
<Icon className="w-5 h-5" />
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Modern Score Indicator
|
||||
function ScoreRing({ score }: { score: number }) {
|
||||
// Score Ring (Desktop) / Badge (Mobile)
|
||||
function ScoreDisplay({ score, mobile = false }: { score: number; mobile?: boolean }) {
|
||||
const color = score >= 80 ? 'text-emerald-500' : score >= 50 ? 'text-amber-500' : 'text-zinc-600'
|
||||
const size = 32
|
||||
|
||||
if (mobile) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
"px-2 py-0.5 rounded text-[10px] font-bold font-mono border",
|
||||
score >= 80 ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" :
|
||||
score >= 50 ? "bg-amber-500/10 text-amber-400 border-amber-500/20" :
|
||||
"bg-zinc-800 text-zinc-400 border-zinc-700"
|
||||
)}>
|
||||
{score}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const size = 36
|
||||
const strokeWidth = 3
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (score / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
{/* Background Ring */}
|
||||
<div className="relative flex items-center justify-center group" style={{ width: size, height: size }}>
|
||||
<svg className="absolute w-full h-full -rotate-90">
|
||||
<circle cx={size/2} cy={size/2} r={radius} className="stroke-zinc-800" strokeWidth={strokeWidth} fill="none" />
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
cx={size/2}
|
||||
cy={size/2}
|
||||
r={radius}
|
||||
className="stroke-zinc-800"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
{/* Progress Ring */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
className={clsx("transition-all duration-500 ease-out", color)}
|
||||
className={clsx("transition-all duration-700 ease-out", color)}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
@ -203,46 +211,26 @@ function ScoreRing({ score }: { score: number }) {
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
<span className={clsx("text-[10px] font-bold font-mono", score >= 80 ? 'text-emerald-400' : 'text-zinc-400')}>
|
||||
<span className={clsx("text-[11px] font-bold font-mono", score >= 80 ? 'text-emerald-400' : 'text-zinc-400')}>
|
||||
{score}
|
||||
</span>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute top-full mt-2 left-1/2 -translate-x-1/2 bg-zinc-900 border border-zinc-800 px-2 py-1 rounded text-[10px] text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-20 pointer-events-none">
|
||||
Pounce Score
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Refined Source Badge
|
||||
function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }) {
|
||||
if (isPounce) return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" />
|
||||
<span className="text-xs font-semibold text-white tracking-tight">Pounce</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<span className="text-xs font-medium text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
{source}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Minimal Toggle
|
||||
function FilterToggle({
|
||||
active,
|
||||
onClick,
|
||||
label
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
label: string
|
||||
}) {
|
||||
function FilterToggle({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-full text-xs font-medium transition-all border",
|
||||
"px-4 py-1.5 rounded-full text-xs font-medium transition-all border whitespace-nowrap",
|
||||
active
|
||||
? "bg-white text-zinc-950 border-white"
|
||||
? "bg-white text-black border-white shadow-[0_0_10px_rgba(255,255,255,0.1)]"
|
||||
: "bg-transparent text-zinc-400 border-zinc-800 hover:border-zinc-700 hover:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
@ -253,34 +241,23 @@ function FilterToggle({
|
||||
|
||||
// Sort Header
|
||||
function SortableHeader({
|
||||
label,
|
||||
field,
|
||||
currentSort,
|
||||
currentDirection,
|
||||
onSort,
|
||||
align = 'left',
|
||||
label, field, currentSort, currentDirection, onSort, align = 'left'
|
||||
}: {
|
||||
label: string
|
||||
field: SortField
|
||||
currentSort: SortField
|
||||
currentDirection: SortDirection
|
||||
onSort: (field: SortField) => void
|
||||
align?: 'left' | 'center' | 'right'
|
||||
label: string; field: SortField; currentSort: SortField; currentDirection: SortDirection; onSort: (field: SortField) => void; align?: 'left'|'center'|'right'
|
||||
}) {
|
||||
const isActive = currentSort === field
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSort(field)}
|
||||
className={clsx(
|
||||
"flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest transition-all group select-none",
|
||||
"flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest transition-all group select-none py-2",
|
||||
align === 'right' && "justify-end ml-auto",
|
||||
align === 'center' && "justify-center mx-auto",
|
||||
isActive ? "text-white" : "text-zinc-500 hover:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<div className={clsx("flex flex-col -space-y-1", isActive ? "opacity-100" : "opacity-0 group-hover:opacity-50")}>
|
||||
<div className={clsx("flex flex-col -space-y-1 transition-opacity", isActive ? "opacity-100" : "opacity-0 group-hover:opacity-30")}>
|
||||
<ChevronUp className={clsx("w-2 h-2", isActive && currentDirection === 'asc' ? "text-white" : "text-zinc-600")} />
|
||||
<ChevronDown className={clsx("w-2 h-2", isActive && currentDirection === 'desc' ? "text-white" : "text-zinc-600")} />
|
||||
</div>
|
||||
@ -295,18 +272,18 @@ function SortableHeader({
|
||||
export default function MarketPage() {
|
||||
const { subscription } = useStore()
|
||||
|
||||
// Data State
|
||||
// Data
|
||||
const [auctions, setAuctions] = useState<Auction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
// Filter State
|
||||
// Filters
|
||||
const [hideSpam, setHideSpam] = useState(true)
|
||||
const [pounceOnly, setPounceOnly] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [priceRange, setPriceRange] = useState<'all' | 'low' | 'mid' | 'high'>('all')
|
||||
|
||||
// Sort State
|
||||
// Sort
|
||||
const [sortField, setSortField] = useState<SortField>('score')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
|
||||
|
||||
@ -314,7 +291,7 @@ export default function MarketPage() {
|
||||
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
|
||||
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
|
||||
|
||||
// Load Data
|
||||
// Load
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@ -327,9 +304,7 @@ export default function MarketPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
@ -338,9 +313,8 @@ export default function MarketPage() {
|
||||
}, [loadData])
|
||||
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
if (sortField === field) setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
|
||||
else {
|
||||
setSortField(field)
|
||||
setSortDirection(field === 'score' || field === 'price' ? 'desc' : 'asc')
|
||||
}
|
||||
@ -352,22 +326,18 @@ export default function MarketPage() {
|
||||
try {
|
||||
await api.addDomain(domain)
|
||||
setTrackedDomains(prev => new Set([...Array.from(prev), domain]))
|
||||
} catch (error) {
|
||||
console.error('Failed to track:', error)
|
||||
} finally {
|
||||
setTrackingInProgress(null)
|
||||
}
|
||||
} catch (error) { console.error(error) } finally { setTrackingInProgress(null) }
|
||||
}, [trackedDomains, trackingInProgress])
|
||||
|
||||
// Transform Data
|
||||
// Transform & Filter
|
||||
const marketItems = useMemo(() => {
|
||||
const items: MarketItem[] = auctions.map(auction => ({
|
||||
id: `${auction.domain}-${auction.platform}`,
|
||||
domain: auction.domain,
|
||||
pounceScore: calculatePounceScore(auction.domain, auction.tld, auction.num_bids, auction.age_years ?? undefined),
|
||||
price: auction.current_bid,
|
||||
priceType: 'bid' as const,
|
||||
status: 'auction' as const,
|
||||
priceType: 'bid',
|
||||
status: 'auction',
|
||||
timeLeft: auction.time_remaining,
|
||||
endTime: auction.end_time,
|
||||
source: auction.platform as any,
|
||||
@ -378,20 +348,13 @@ export default function MarketPage() {
|
||||
}))
|
||||
|
||||
let filtered = items
|
||||
|
||||
// Filters
|
||||
if (hideSpam) filtered = filtered.filter(item => !isSpamDomain(item.domain, item.tld))
|
||||
if (pounceOnly) filtered = filtered.filter(item => item.isPounce)
|
||||
if (priceRange === 'low') filtered = filtered.filter(item => item.price < 100)
|
||||
if (priceRange === 'mid') filtered = filtered.filter(item => item.price >= 100 && item.price < 1000)
|
||||
if (priceRange === 'high') filtered = filtered.filter(item => item.price >= 1000)
|
||||
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
filtered = filtered.filter(item => item.domain.toLowerCase().includes(q))
|
||||
}
|
||||
if (searchQuery) filtered = filtered.filter(item => item.domain.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
|
||||
// Sort
|
||||
const mult = sortDirection === 'asc' ? 1 : -1
|
||||
filtered.sort((a, b) => {
|
||||
switch (sortField) {
|
||||
@ -403,7 +366,6 @@ export default function MarketPage() {
|
||||
default: return 0
|
||||
}
|
||||
})
|
||||
|
||||
return filtered
|
||||
}, [auctions, hideSpam, pounceOnly, priceRange, searchQuery, sortField, sortDirection])
|
||||
|
||||
@ -412,239 +374,191 @@ export default function MarketPage() {
|
||||
total: marketItems.length,
|
||||
highScore: marketItems.filter(i => i.pounceScore >= 80).length,
|
||||
endingSoon: marketItems.filter(i => parseTimeToSeconds(i.timeLeft) < 3600).length,
|
||||
avgPrice: marketItems.length > 0
|
||||
? Math.round(marketItems.reduce((acc, i) => acc + i.price, 0) / marketItems.length)
|
||||
: 0
|
||||
avgPrice: marketItems.length > 0 ? Math.round(marketItems.reduce((acc, i) => acc + i.price, 0) / marketItems.length) : 0
|
||||
}), [marketItems])
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(price)
|
||||
}
|
||||
const formatPrice = (price: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(price)
|
||||
|
||||
return (
|
||||
<TerminalLayout title="Market" subtitle="Real-time Domain Auctions">
|
||||
<div className="space-y-8">
|
||||
<TerminalLayout title="Market" subtitle="Global Domain Opportunities">
|
||||
<div className="space-y-6 pb-20 md:pb-0">
|
||||
|
||||
{/* ============================================================================ */}
|
||||
{/* METRICS GRID */}
|
||||
{/* ============================================================================ */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Active Listings"
|
||||
value={stats.total}
|
||||
icon={Activity}
|
||||
trend="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label="High Potential"
|
||||
value={stats.highScore}
|
||||
subValue="Score 80+"
|
||||
icon={TrendingUp}
|
||||
trend="up"
|
||||
/>
|
||||
<StatCard
|
||||
label="Ending Soon"
|
||||
value={stats.endingSoon}
|
||||
subValue="< 1h"
|
||||
icon={Flame}
|
||||
trend={stats.endingSoon > 5 ? 'down' : 'neutral'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg. Price"
|
||||
value={formatPrice(stats.avgPrice)}
|
||||
icon={Zap}
|
||||
trend="neutral"
|
||||
/>
|
||||
{/* METRICS */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Active" value={stats.total} icon={Activity} trend="neutral" />
|
||||
<StatCard label="Top Tier" value={stats.highScore} subValue="80+ Score" icon={TrendingUp} trend="up" />
|
||||
<StatCard label="Ending Soon" value={stats.endingSoon} subValue="< 1h" icon={Flame} trend={stats.endingSoon > 5 ? 'down' : 'neutral'} />
|
||||
<StatCard label="Avg Price" value={formatPrice(stats.avgPrice)} icon={Zap} trend="neutral" />
|
||||
</div>
|
||||
|
||||
{/* ============================================================================ */}
|
||||
{/* CONTROL BAR */}
|
||||
{/* ============================================================================ */}
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 p-1">
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full md:w-96 group">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
||||
<Search className="w-4 h-4 text-zinc-500 group-focus-within:text-white transition-colors" />
|
||||
{/* CONTROLS */}
|
||||
<div className="sticky top-0 z-30 bg-zinc-950/80 backdrop-blur-md py-4 border-b border-white/5 -mx-4 px-4 md:mx-0 md:px-0 md:border-none md:bg-transparent md:static">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative w-full md:w-80 group">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500 group-focus-within:text-white transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search domains..."
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-zinc-900 border border-white/10 rounded-xl
|
||||
text-sm text-white placeholder:text-zinc-600
|
||||
focus:outline-none focus:border-white/20 focus:ring-1 focus:ring-white/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search domains..."
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-zinc-900/50 border border-white/5 rounded-lg
|
||||
text-sm text-white placeholder:text-zinc-600
|
||||
focus:outline-none focus:border-white/20 focus:bg-zinc-900 transition-all"
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto w-full pb-1 md:pb-0 scrollbar-hide mask-fade-right">
|
||||
<FilterToggle active={hideSpam} onClick={() => setHideSpam(!hideSpam)} label="No Spam" />
|
||||
<FilterToggle active={pounceOnly} onClick={() => setPounceOnly(!pounceOnly)} label="Pounce Exclusive" />
|
||||
<div className="w-px h-5 bg-white/10 mx-2 flex-shrink-0" />
|
||||
<FilterToggle active={priceRange === 'low'} onClick={() => setPriceRange(p => p === 'low' ? 'all' : 'low')} label="< $100" />
|
||||
<FilterToggle active={priceRange === 'high'} onClick={() => setPriceRange(p => p === 'high' ? 'all' : 'high')} label="$1k+" />
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block flex-1" />
|
||||
|
||||
<button onClick={handleRefresh} className="hidden md:flex items-center gap-2 text-xs font-medium text-zinc-500 hover:text-white transition-colors">
|
||||
<RefreshCw className={clsx("w-3.5 h-3.5", refreshing && "animate-spin")} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 hidden md:block" />
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto w-full md:w-auto pb-2 md:pb-0 scrollbar-hide">
|
||||
<FilterToggle active={hideSpam} onClick={() => setHideSpam(!hideSpam)} label="Hide Spam" />
|
||||
<FilterToggle active={pounceOnly} onClick={() => setPounceOnly(!pounceOnly)} label="Pounce Exclusive" />
|
||||
<div className="w-px h-4 bg-white/10 mx-1" />
|
||||
<FilterToggle active={priceRange === 'low'} onClick={() => setPriceRange(p => p === 'low' ? 'all' : 'low')} label="< $100" />
|
||||
<FilterToggle active={priceRange === 'high'} onClick={() => setPriceRange(p => p === 'high' ? 'all' : 'high')} label="$1k+" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Refresh */}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 text-xs font-medium text-zinc-500 hover:text-white transition-colors",
|
||||
refreshing && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={clsx("w-3.5 h-3.5", refreshing && "animate-spin")} />
|
||||
<span className="hidden sm:inline">Refresh Data</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ============================================================================ */}
|
||||
{/* DATA GRID */}
|
||||
{/* ============================================================================ */}
|
||||
<div className="border border-white/5 rounded-xl overflow-hidden bg-zinc-900/20 backdrop-blur-sm">
|
||||
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-12 gap-4 px-6 py-3 border-b border-white/5 bg-zinc-900/50">
|
||||
<div className="col-span-5 md:col-span-4">
|
||||
<SortableHeader label="Domain Asset" field="domain" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} />
|
||||
</div>
|
||||
<div className="col-span-2 hidden md:block text-center">
|
||||
<SortableHeader label="Pounce Score" field="score" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
|
||||
</div>
|
||||
<div className="col-span-3 md:col-span-2 text-right">
|
||||
<SortableHeader label="Bid Price" field="price" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="right" />
|
||||
</div>
|
||||
<div className="col-span-2 hidden lg:block text-center">
|
||||
<SortableHeader label="Time Left" field="time" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
|
||||
</div>
|
||||
<div className="col-span-2 hidden xl:block text-center">
|
||||
<SortableHeader label="Source" field="source" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
|
||||
</div>
|
||||
<div className="col-span-4 md:col-span-2 xl:col-span-2 text-right">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-600">Action</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="min-h-[400px]">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-32 space-y-4">
|
||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
<p className="text-zinc-500 text-sm animate-pulse">Scanning global markets...</p>
|
||||
<p className="text-zinc-500 text-sm animate-pulse">Scanning markets...</p>
|
||||
</div>
|
||||
) : marketItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-32 text-center">
|
||||
<div className="w-16 h-16 bg-zinc-900 rounded-full flex items-center justify-center mb-4 border border-zinc-800">
|
||||
<Search className="w-6 h-6 text-zinc-600" />
|
||||
</div>
|
||||
<h3 className="text-white font-medium mb-1">No assets found</h3>
|
||||
<p className="text-zinc-500 text-sm">Adjust filters to see more results</p>
|
||||
<h3 className="text-white font-medium mb-1">No matches found</h3>
|
||||
<p className="text-zinc-500 text-sm">Try adjusting your filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-white/5">
|
||||
{marketItems.map((item) => {
|
||||
const timeLeftSec = parseTimeToSeconds(item.timeLeft)
|
||||
const isUrgent = timeLeftSec < 3600
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid grid-cols-12 gap-4 px-6 py-3.5 items-center hover:bg-white/[0.02] transition-colors group"
|
||||
>
|
||||
{/* Domain */}
|
||||
<div className="col-span-5 md:col-span-4 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx(
|
||||
"w-1.5 h-1.5 rounded-full flex-shrink-0",
|
||||
item.isPounce ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" : "bg-zinc-700"
|
||||
)} />
|
||||
<div className="truncate">
|
||||
<div className="flex items-baseline gap-0.5 truncate">
|
||||
<span className="font-medium text-white text-[15px] tracking-tight">{item.domain.split('.')[0]}</span>
|
||||
<span className="text-zinc-500 text-sm">.{item.tld}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 lg:hidden">
|
||||
<span className={clsx("text-xs", item.pounceScore >= 80 ? "text-emerald-400" : "text-zinc-500")}>
|
||||
Score {item.pounceScore}
|
||||
</span>
|
||||
<span className="text-zinc-700 text-[10px]">•</span>
|
||||
<span className={clsx("text-xs", isUrgent ? "text-red-400" : "text-zinc-500")}>
|
||||
{item.timeLeft}
|
||||
</span>
|
||||
<>
|
||||
{/* DESKTOP TABLE */}
|
||||
<div className="hidden md:block border border-white/5 rounded-xl overflow-hidden bg-zinc-900/40 backdrop-blur-sm shadow-xl">
|
||||
<div className="grid grid-cols-12 gap-4 px-6 py-3 border-b border-white/5 bg-white/[0.02]">
|
||||
<div className="col-span-4"><SortableHeader label="Domain Asset" field="domain" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} /></div>
|
||||
<div className="col-span-2 text-center"><SortableHeader label="Score" field="score" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" /></div>
|
||||
<div className="col-span-2 text-right"><SortableHeader label="Price" field="price" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="right" /></div>
|
||||
<div className="col-span-2 text-center"><SortableHeader label="Time" field="time" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" /></div>
|
||||
<div className="col-span-2 text-right"><span className="text-[10px] font-bold uppercase tracking-widest text-zinc-600 py-2 block">Action</span></div>
|
||||
</div>
|
||||
<div className="divide-y divide-white/5">
|
||||
{marketItems.map((item) => {
|
||||
const timeLeftSec = parseTimeToSeconds(item.timeLeft)
|
||||
const isUrgent = timeLeftSec < 3600
|
||||
return (
|
||||
<div key={item.id} className="grid grid-cols-12 gap-4 px-6 py-4 items-center hover:bg-white/[0.04] transition-all group relative">
|
||||
{/* Domain */}
|
||||
<div className="col-span-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{item.isPounce && <Diamond className="w-4 h-4 text-emerald-400 fill-emerald-400/20" />}
|
||||
<div>
|
||||
<div className="font-medium text-white text-[15px] tracking-tight">{item.domain}</div>
|
||||
<div className="text-[11px] text-zinc-500 mt-0.5">{item.source}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div className="col-span-2 hidden md:flex justify-center">
|
||||
<ScoreRing score={item.pounceScore} />
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="col-span-3 md:col-span-2 text-right">
|
||||
<div className="font-mono text-white font-medium tracking-tight">
|
||||
{formatPrice(item.price)}
|
||||
</div>
|
||||
{item.numBids !== undefined && item.numBids > 0 && (
|
||||
<div className="text-[11px] text-zinc-500 mt-0.5">
|
||||
{item.numBids} bids
|
||||
{/* Score */}
|
||||
<div className="col-span-2 flex justify-center"><ScoreDisplay score={item.pounceScore} /></div>
|
||||
{/* Price */}
|
||||
<div className="col-span-2 text-right">
|
||||
<div className="font-mono text-white font-medium">{formatPrice(item.price)}</div>
|
||||
{item.numBids !== undefined && item.numBids > 0 && <div className="text-[10px] text-zinc-500 mt-0.5">{item.numBids} bids</div>}
|
||||
</div>
|
||||
{/* Time */}
|
||||
<div className="col-span-2 flex justify-center">
|
||||
<div className={clsx("flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium", isUrgent ? "text-red-400 bg-red-500/10" : "text-zinc-400 bg-zinc-800/50")}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{item.timeLeft}
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="col-span-2 flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => handleTrack(item.domain)} disabled={trackedDomains.has(item.domain)} className={clsx("w-8 h-8 flex items-center justify-center rounded-lg border transition-colors", trackedDomains.has(item.domain) ? "bg-emerald-500 text-white border-emerald-500" : "border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-500 hover:bg-zinc-800")}>
|
||||
{trackedDomains.has(item.domain) ? <Check className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
|
||||
</button>
|
||||
<a href={item.affiliateUrl || '#'} target="_blank" rel="noopener noreferrer" className="h-8 px-3 flex items-center gap-2 bg-white text-zinc-950 rounded-lg text-xs font-semibold hover:bg-zinc-200 transition-colors">
|
||||
{item.isPounce ? 'Buy' : 'Bid'}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="col-span-2 hidden lg:flex justify-center">
|
||||
<div className={clsx(
|
||||
"flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border",
|
||||
isUrgent
|
||||
? "bg-red-500/10 text-red-400 border-red-500/20"
|
||||
: "bg-zinc-900 border-zinc-800 text-zinc-400"
|
||||
)}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{item.timeLeft}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="col-span-2 hidden xl:flex justify-center">
|
||||
<SourceBadge source={item.source} isPounce={item.isPounce} />
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="col-span-4 md:col-span-2 xl:col-span-2 flex items-center justify-end gap-2 opacity-100 lg:opacity-60 lg:group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleTrack(item.domain)}
|
||||
disabled={trackedDomains.has(item.domain)}
|
||||
className={clsx(
|
||||
"w-8 h-8 flex items-center justify-center rounded-lg border transition-all",
|
||||
trackedDomains.has(item.domain)
|
||||
? "bg-emerald-500 text-white border-emerald-500"
|
||||
: "border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-500 hover:bg-zinc-800"
|
||||
)}
|
||||
>
|
||||
{trackedDomains.has(item.domain) ? <Check className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
|
||||
</button>
|
||||
{/* MOBILE CARDS */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{marketItems.map((item) => {
|
||||
const timeLeftSec = parseTimeToSeconds(item.timeLeft)
|
||||
const isUrgent = timeLeftSec < 3600
|
||||
return (
|
||||
<div key={item.id} className="bg-zinc-900/40 border border-white/5 rounded-xl p-4 active:bg-zinc-900/60 transition-colors">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.isPounce && <Diamond className="w-3.5 h-3.5 text-emerald-400 fill-emerald-400/20" />}
|
||||
<span className="font-medium text-white text-base">{item.domain}</span>
|
||||
</div>
|
||||
<ScoreDisplay score={item.pounceScore} mobile />
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={item.affiliateUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-8 px-3 flex items-center gap-2 bg-white text-zinc-950 rounded-lg text-xs font-semibold hover:bg-zinc-200 transition-colors"
|
||||
>
|
||||
{item.isPounce ? 'Buy' : 'Bid'}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="text-[10px] text-zinc-500 uppercase tracking-wider mb-0.5">Current Bid</div>
|
||||
<div className="font-mono text-lg font-medium text-white">{formatPrice(item.price)}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-zinc-500 uppercase tracking-wider mb-0.5">Ends In</div>
|
||||
<div className={clsx("flex items-center gap-1.5 justify-end font-medium", isUrgent ? "text-red-400" : "text-zinc-400")}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{item.timeLeft}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => handleTrack(item.domain)}
|
||||
disabled={trackedDomains.has(item.domain)}
|
||||
className={clsx(
|
||||
"flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium border transition-all",
|
||||
trackedDomains.has(item.domain)
|
||||
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
|
||||
: "bg-zinc-800/50 text-zinc-400 border-zinc-700/50"
|
||||
)}
|
||||
>
|
||||
{trackedDomains.has(item.domain) ? (
|
||||
<><Check className="w-4 h-4" /> Tracked</>
|
||||
) : (
|
||||
<><Plus className="w-4 h-4" /> Watch</>
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href={item.affiliateUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-semibold bg-white text-black hover:bg-zinc-200 transition-colors"
|
||||
>
|
||||
{item.isPounce ? 'Buy Now' : 'Place Bid'}
|
||||
<ExternalLink className="w-3 h-3 opacity-50" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user