feat: MARKET - Add sortable columns + new Live Feed header style
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Changes: - Sortable columns: Domain, Score, Price, Time Left, Source - Click column header to sort (asc/desc toggle) - New header: 'Live Market Feed' with live indicator - Quick stats pills: total listings, high score count, ending soon - Visual sort indicators (chevron up/down) - Default sort: Score descending
This commit is contained in:
@ -67,7 +67,7 @@ BASE_TEMPLATE = """
|
|||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div style="padding: 40px;">
|
<div style="padding: 40px;">
|
||||||
{{ content }}
|
{{ content }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useEffect, useState, useMemo, useCallback } from 'react'
|
|||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { TerminalLayout } from '@/components/TerminalLayout'
|
import { TerminalLayout } from '@/components/TerminalLayout'
|
||||||
import {
|
import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Loader2,
|
Loader2,
|
||||||
Diamond,
|
Diamond,
|
||||||
@ -18,8 +18,9 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
Sparkles,
|
Activity,
|
||||||
BarChart3,
|
Flame,
|
||||||
|
Clock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
@ -72,7 +73,7 @@ function calculatePounceScore(domain: string, tld: string, numBids?: number, age
|
|||||||
let score = 50
|
let score = 50
|
||||||
const name = domain.split('.')[0]
|
const name = domain.split('.')[0]
|
||||||
|
|
||||||
// Length bonus
|
// Length bonus (shorter = better)
|
||||||
if (name.length <= 3) score += 30
|
if (name.length <= 3) score += 30
|
||||||
else if (name.length === 4) score += 25
|
else if (name.length === 4) score += 25
|
||||||
else if (name.length === 5) score += 20
|
else if (name.length === 5) score += 20
|
||||||
@ -90,7 +91,7 @@ function calculatePounceScore(domain: string, tld: string, numBids?: number, age
|
|||||||
else if (ageYears && ageYears > 10) score += 7
|
else if (ageYears && ageYears > 10) score += 7
|
||||||
else if (ageYears && ageYears > 5) score += 3
|
else if (ageYears && ageYears > 5) score += 3
|
||||||
|
|
||||||
// Activity bonus
|
// Activity bonus (more bids = more valuable)
|
||||||
if (numBids && numBids >= 20) score += 8
|
if (numBids && numBids >= 20) score += 8
|
||||||
else if (numBids && numBids >= 10) score += 5
|
else if (numBids && numBids >= 10) score += 5
|
||||||
else if (numBids && numBids >= 5) score += 2
|
else if (numBids && numBids >= 5) score += 2
|
||||||
@ -116,16 +117,13 @@ function isSpamDomain(domain: string, tld: string): boolean {
|
|||||||
// Parse time remaining to seconds for sorting
|
// Parse time remaining to seconds for sorting
|
||||||
function parseTimeToSeconds(timeStr?: string): number {
|
function parseTimeToSeconds(timeStr?: string): number {
|
||||||
if (!timeStr) return Infinity
|
if (!timeStr) return Infinity
|
||||||
|
|
||||||
let seconds = 0
|
let seconds = 0
|
||||||
const days = timeStr.match(/(\d+)d/)
|
const days = timeStr.match(/(\d+)d/)
|
||||||
const hours = timeStr.match(/(\d+)h/)
|
const hours = timeStr.match(/(\d+)h/)
|
||||||
const mins = timeStr.match(/(\d+)m/)
|
const mins = timeStr.match(/(\d+)m/)
|
||||||
|
|
||||||
if (days) seconds += parseInt(days[1]) * 86400
|
if (days) seconds += parseInt(days[1]) * 86400
|
||||||
if (hours) seconds += parseInt(hours[1]) * 3600
|
if (hours) seconds += parseInt(hours[1]) * 3600
|
||||||
if (mins) seconds += parseInt(mins[1]) * 60
|
if (mins) seconds += parseInt(mins[1]) * 60
|
||||||
|
|
||||||
return seconds || Infinity
|
return seconds || Infinity
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,7 +131,7 @@ function parseTimeToSeconds(timeStr?: string): number {
|
|||||||
// COMPONENTS
|
// COMPONENTS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// Score Badge
|
// Score Badge with color coding
|
||||||
function ScoreBadge({ score }: { score: number }) {
|
function ScoreBadge({ score }: { score: number }) {
|
||||||
const color = score >= 80
|
const color = score >= 80
|
||||||
? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'
|
? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'
|
||||||
@ -142,12 +140,12 @@ function ScoreBadge({ score }: { score: number }) {
|
|||||||
: 'bg-red-500/20 text-red-400 border-red-500/30'
|
: 'bg-red-500/20 text-red-400 border-red-500/30'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={clsx(
|
<div className={clsx(
|
||||||
"inline-flex items-center justify-center w-12 h-8 rounded-lg border font-mono text-sm font-bold",
|
"inline-flex items-center justify-center w-12 h-8 rounded-md border font-mono text-sm font-bold",
|
||||||
color
|
color
|
||||||
)}>
|
)}>
|
||||||
{score}
|
{score}
|
||||||
</span>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,22 +153,25 @@ function ScoreBadge({ score }: { score: number }) {
|
|||||||
function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }) {
|
function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }) {
|
||||||
if (isPounce) {
|
if (isPounce) {
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center gap-1 px-2 py-1 bg-emerald-500/10 border border-emerald-500/30 rounded">
|
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-emerald-500/10 border border-emerald-500/30 rounded-md">
|
||||||
<Diamond className="w-3 h-3 text-emerald-400" />
|
<Diamond className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span className="text-[11px] font-bold text-emerald-400 uppercase">Pounce</span>
|
<span className="text-xs font-semibold text-emerald-400">Pounce</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
GoDaddy: 'text-orange-400/80',
|
GoDaddy: 'bg-orange-500/10 border-orange-500/20 text-orange-400',
|
||||||
Sedo: 'text-blue-400/80',
|
Sedo: 'bg-blue-500/10 border-blue-500/20 text-blue-400',
|
||||||
NameJet: 'text-purple-400/80',
|
NameJet: 'bg-purple-500/10 border-purple-500/20 text-purple-400',
|
||||||
DropCatch: 'text-cyan-400/80',
|
DropCatch: 'bg-cyan-500/10 border-cyan-500/20 text-cyan-400',
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={clsx("text-[11px] font-medium uppercase tracking-wide", colors[source] || 'text-zinc-500')}>
|
<span className={clsx(
|
||||||
|
"inline-flex items-center px-2.5 py-1 rounded-md border text-xs font-medium",
|
||||||
|
colors[source] || 'bg-zinc-800 border-zinc-700 text-zinc-400'
|
||||||
|
)}>
|
||||||
{source}
|
{source}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@ -180,9 +181,9 @@ function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }
|
|||||||
function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; timeLeft?: string }) {
|
function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; timeLeft?: string }) {
|
||||||
if (status === 'instant') {
|
if (status === 'instant') {
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center gap-1 px-2 py-1 bg-emerald-500/15 rounded">
|
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-emerald-500/20 border border-emerald-500/30 rounded-md">
|
||||||
<Zap className="w-3 h-3 text-emerald-400" />
|
<Zap className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span className="text-[11px] font-bold text-emerald-400 uppercase">Instant</span>
|
<span className="text-xs font-bold text-emerald-400">Instant</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -192,16 +193,18 @@ function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; time
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
"inline-flex items-center gap-1 px-2 py-1 rounded",
|
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border",
|
||||||
isUrgent ? "bg-red-500/15" : isWarning ? "bg-amber-500/15" : "bg-zinc-800/50"
|
isUrgent ? "bg-red-500/20 border-red-500/30" :
|
||||||
|
isWarning ? "bg-amber-500/20 border-amber-500/30" :
|
||||||
|
"bg-zinc-800 border-zinc-700"
|
||||||
)}>
|
)}>
|
||||||
<Timer className={clsx(
|
<Timer className={clsx(
|
||||||
"w-3 h-3",
|
"w-3.5 h-3.5",
|
||||||
isUrgent ? "text-red-400" : isWarning ? "text-amber-400" : "text-zinc-500"
|
isUrgent ? "text-red-400" : isWarning ? "text-amber-400" : "text-zinc-400"
|
||||||
)} />
|
)} />
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"text-[11px] font-medium",
|
"text-xs font-medium",
|
||||||
isUrgent ? "text-red-400" : isWarning ? "text-amber-400" : "text-zinc-500"
|
isUrgent ? "text-red-400" : isWarning ? "text-amber-400" : "text-zinc-400"
|
||||||
)}>
|
)}>
|
||||||
{timeLeft}
|
{timeLeft}
|
||||||
</span>
|
</span>
|
||||||
@ -209,15 +212,70 @@ function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; time
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sortable Column Header
|
// Toggle Button
|
||||||
function SortHeader({
|
function ToggleButton({
|
||||||
label,
|
active,
|
||||||
field,
|
onClick,
|
||||||
currentSort,
|
children
|
||||||
currentDirection,
|
|
||||||
onSort,
|
|
||||||
align = 'left'
|
|
||||||
}: {
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all",
|
||||||
|
active
|
||||||
|
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
|
||||||
|
: "bg-zinc-800/50 text-zinc-400 border border-zinc-700/50 hover:bg-zinc-800 hover:text-zinc-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{active && <Check className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropdown Select
|
||||||
|
function DropdownSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
options: { value: string; label: string }[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="appearance-none px-4 py-2 pr-10 bg-zinc-800/50 border border-zinc-700/50 rounded-lg
|
||||||
|
text-sm text-zinc-300 font-medium cursor-pointer
|
||||||
|
hover:bg-zinc-800 hover:border-zinc-600 transition-all
|
||||||
|
focus:outline-none focus:border-emerald-500/50"
|
||||||
|
>
|
||||||
|
{options.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortable Column Header
|
||||||
|
function SortableHeader({
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
currentSort,
|
||||||
|
currentDirection,
|
||||||
|
onSort,
|
||||||
|
align = 'left',
|
||||||
|
}: {
|
||||||
label: string
|
label: string
|
||||||
field: SortField
|
field: SortField
|
||||||
currentSort: SortField
|
currentSort: SortField
|
||||||
@ -231,64 +289,26 @@ function SortHeader({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onSort(field)}
|
onClick={() => onSort(field)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider transition-colors group",
|
"flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider transition-colors group",
|
||||||
align === 'right' && "justify-end",
|
align === 'right' && "justify-end ml-auto",
|
||||||
align === 'center' && "justify-center",
|
align === 'center' && "justify-center mx-auto",
|
||||||
isActive ? "text-emerald-400" : "text-zinc-500 hover:text-zinc-300"
|
isActive ? "text-emerald-400" : "text-zinc-500 hover:text-zinc-300"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
<span className={clsx(
|
<span className={clsx("transition-all", isActive ? "opacity-100" : "opacity-0 group-hover:opacity-50")}>
|
||||||
"transition-opacity",
|
|
||||||
isActive ? "opacity-100" : "opacity-0 group-hover:opacity-50"
|
|
||||||
)}>
|
|
||||||
{isActive && currentDirection === 'desc' ? (
|
{isActive && currentDirection === 'desc' ? (
|
||||||
<ChevronDown className="w-3.5 h-3.5" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
) : isActive && currentDirection === 'asc' ? (
|
) : isActive && currentDirection === 'asc' ? (
|
||||||
<ChevronUp className="w-3.5 h-3.5" />
|
<ChevronUp className="w-4 h-4" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowUpDown className="w-3 h-3" />
|
<ArrowUpDown className="w-3.5 h-3.5" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle Button
|
|
||||||
function ToggleButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={clsx(
|
|
||||||
"flex items-center gap-2 px-3 py-1.5 rounded text-xs font-medium transition-all",
|
|
||||||
active
|
|
||||||
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
|
|
||||||
: "bg-zinc-800/50 text-zinc-500 border border-zinc-700/50 hover:text-zinc-300 hover:border-zinc-600"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{active && <Check className="w-3 h-3" />}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dropdown
|
|
||||||
function Dropdown({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { value: string; label: string }[] }) {
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="appearance-none px-3 py-1.5 pr-8 bg-zinc-800/50 border border-zinc-700/50 rounded
|
|
||||||
text-xs text-zinc-400 font-medium cursor-pointer
|
|
||||||
hover:border-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all"
|
|
||||||
>
|
|
||||||
{options.map(opt => (
|
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MAIN COMPONENT
|
// MAIN COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -296,26 +316,27 @@ function Dropdown({ value, onChange, options }: { value: string; onChange: (v: s
|
|||||||
export default function MarketPage() {
|
export default function MarketPage() {
|
||||||
const { subscription } = useStore()
|
const { subscription } = useStore()
|
||||||
|
|
||||||
// Data
|
// Data State
|
||||||
const [auctions, setAuctions] = useState<Auction[]>([])
|
const [auctions, setAuctions] = useState<Auction[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
// Filters
|
// Filter State
|
||||||
const [hideSpam, setHideSpam] = useState(true)
|
const [hideSpam, setHideSpam] = useState(true)
|
||||||
const [pounceOnly, setPounceOnly] = useState(false)
|
const [pounceOnly, setPounceOnly] = useState(false)
|
||||||
const [selectedTld, setSelectedTld] = useState('all')
|
const [selectedTld, setSelectedTld] = useState('all')
|
||||||
const [selectedPrice, setSelectedPrice] = useState('all')
|
const [selectedPrice, setSelectedPrice] = useState('all')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
// Sorting
|
// Sort State
|
||||||
const [sortField, setSortField] = useState<SortField>('score')
|
const [sortField, setSortField] = useState<SortField>('score')
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
|
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
|
||||||
|
|
||||||
// Watchlist
|
// Watchlist State
|
||||||
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
|
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
|
||||||
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
|
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Options
|
||||||
const TLD_OPTIONS = [
|
const TLD_OPTIONS = [
|
||||||
{ value: 'all', label: 'All TLDs' },
|
{ value: 'all', label: 'All TLDs' },
|
||||||
{ value: 'com', label: '.com' },
|
{ value: 'com', label: '.com' },
|
||||||
@ -328,7 +349,7 @@ export default function MarketPage() {
|
|||||||
const PRICE_OPTIONS = [
|
const PRICE_OPTIONS = [
|
||||||
{ value: 'all', label: 'Any Price' },
|
{ value: 'all', label: 'Any Price' },
|
||||||
{ value: '100', label: '< $100' },
|
{ value: '100', label: '< $100' },
|
||||||
{ value: '1000', label: '< $1k' },
|
{ value: '1000', label: '< $1,000' },
|
||||||
{ value: '10000', label: 'High Roller' },
|
{ value: '10000', label: 'High Roller' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -339,7 +360,7 @@ export default function MarketPage() {
|
|||||||
const data = await api.getAuctions()
|
const data = await api.getAuctions()
|
||||||
setAuctions(data.auctions || [])
|
setAuctions(data.auctions || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load:', error)
|
console.error('Failed to load market data:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -360,234 +381,339 @@ export default function MarketPage() {
|
|||||||
setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
|
setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
|
||||||
} else {
|
} else {
|
||||||
setSortField(field)
|
setSortField(field)
|
||||||
setSortDirection(field === 'domain' || field === 'source' ? 'asc' : 'desc')
|
// Default direction based on field type
|
||||||
|
setSortDirection(field === 'score' || field === 'price' ? 'desc' : 'asc')
|
||||||
}
|
}
|
||||||
}, [sortField])
|
}, [sortField])
|
||||||
|
|
||||||
const handleTrack = useCallback(async (domain: string) => {
|
const handleTrack = useCallback(async (domain: string) => {
|
||||||
if (trackedDomains.has(domain) || trackingInProgress) return
|
if (trackedDomains.has(domain) || trackingInProgress) return
|
||||||
|
|
||||||
setTrackingInProgress(domain)
|
setTrackingInProgress(domain)
|
||||||
try {
|
try {
|
||||||
await api.addDomain(domain)
|
await api.addDomain(domain)
|
||||||
setTrackedDomains(prev => new Set([...Array.from(prev), domain]))
|
setTrackedDomains(prev => new Set([...Array.from(prev), domain]))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed:', error)
|
console.error('Failed to track:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setTrackingInProgress(null)
|
setTrackingInProgress(null)
|
||||||
}
|
}
|
||||||
}, [trackedDomains, trackingInProgress])
|
}, [trackedDomains, trackingInProgress])
|
||||||
|
|
||||||
// Process Data
|
// Transform and Filter Data
|
||||||
const marketItems = useMemo(() => {
|
const marketItems = useMemo(() => {
|
||||||
let items: MarketItem[] = auctions.map(a => ({
|
// Convert auctions to market items
|
||||||
id: `${a.domain}-${a.platform}`,
|
const items: MarketItem[] = auctions.map(auction => ({
|
||||||
domain: a.domain,
|
id: `${auction.domain}-${auction.platform}`,
|
||||||
pounceScore: calculatePounceScore(a.domain, a.tld, a.num_bids, a.age_years ?? undefined),
|
domain: auction.domain,
|
||||||
price: a.current_bid,
|
pounceScore: calculatePounceScore(auction.domain, auction.tld, auction.num_bids, auction.age_years ?? undefined),
|
||||||
|
price: auction.current_bid,
|
||||||
priceType: 'bid' as const,
|
priceType: 'bid' as const,
|
||||||
status: 'auction' as const,
|
status: 'auction' as const,
|
||||||
timeLeft: a.time_remaining,
|
timeLeft: auction.time_remaining,
|
||||||
endTime: a.end_time,
|
endTime: auction.end_time,
|
||||||
source: a.platform as any,
|
source: auction.platform as any,
|
||||||
isPounce: false,
|
isPounce: false,
|
||||||
affiliateUrl: a.affiliate_url,
|
affiliateUrl: auction.affiliate_url,
|
||||||
tld: a.tld,
|
tld: auction.tld,
|
||||||
numBids: a.num_bids,
|
numBids: auction.num_bids,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Filter
|
// Apply Filters
|
||||||
if (hideSpam) items = items.filter(i => !isSpamDomain(i.domain, i.tld))
|
let filtered = items
|
||||||
if (pounceOnly) items = items.filter(i => i.isPounce)
|
|
||||||
if (selectedTld !== 'all') items = items.filter(i => i.tld === selectedTld)
|
if (hideSpam) {
|
||||||
if (selectedPrice !== 'all') {
|
filtered = filtered.filter(item => !isSpamDomain(item.domain, item.tld))
|
||||||
const max = parseInt(selectedPrice)
|
|
||||||
items = selectedPrice === '10000'
|
|
||||||
? items.filter(i => i.price >= 10000)
|
|
||||||
: items.filter(i => i.price < max)
|
|
||||||
}
|
|
||||||
if (searchQuery) {
|
|
||||||
const q = searchQuery.toLowerCase()
|
|
||||||
items = items.filter(i => i.domain.toLowerCase().includes(q))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort
|
if (pounceOnly) {
|
||||||
items.sort((a, b) => {
|
filtered = filtered.filter(item => item.isPounce)
|
||||||
const mult = sortDirection === 'asc' ? 1 : -1
|
}
|
||||||
|
|
||||||
|
if (selectedTld !== 'all') {
|
||||||
|
filtered = filtered.filter(item => item.tld === selectedTld)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedPrice !== 'all') {
|
||||||
|
const maxPrice = parseInt(selectedPrice)
|
||||||
|
if (selectedPrice === '10000') {
|
||||||
|
filtered = filtered.filter(item => item.price >= 10000)
|
||||||
|
} else {
|
||||||
|
filtered = filtered.filter(item => item.price < maxPrice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase()
|
||||||
|
filtered = filtered.filter(item => item.domain.toLowerCase().includes(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Sort
|
||||||
|
const mult = sortDirection === 'asc' ? 1 : -1
|
||||||
|
filtered.sort((a, b) => {
|
||||||
switch (sortField) {
|
switch (sortField) {
|
||||||
case 'domain': return mult * a.domain.localeCompare(b.domain)
|
case 'domain':
|
||||||
case 'score': return mult * (a.pounceScore - b.pounceScore)
|
return mult * a.domain.localeCompare(b.domain)
|
||||||
case 'price': return mult * (a.price - b.price)
|
case 'score':
|
||||||
case 'time': return mult * (parseTimeToSeconds(a.timeLeft) - parseTimeToSeconds(b.timeLeft))
|
return mult * (a.pounceScore - b.pounceScore)
|
||||||
case 'source': return mult * a.source.localeCompare(b.source)
|
case 'price':
|
||||||
default: return 0
|
return mult * (a.price - b.price)
|
||||||
|
case 'time':
|
||||||
|
return mult * (parseTimeToSeconds(a.timeLeft) - parseTimeToSeconds(b.timeLeft))
|
||||||
|
case 'source':
|
||||||
|
return mult * a.source.localeCompare(b.source)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return items
|
return filtered
|
||||||
}, [auctions, hideSpam, pounceOnly, selectedTld, selectedPrice, searchQuery, sortField, sortDirection])
|
}, [auctions, hideSpam, pounceOnly, selectedTld, selectedPrice, searchQuery, sortField, sortDirection])
|
||||||
|
|
||||||
|
// Stats
|
||||||
const stats = useMemo(() => ({
|
const stats = useMemo(() => ({
|
||||||
total: marketItems.length,
|
total: marketItems.length,
|
||||||
highScore: marketItems.filter(i => i.pounceScore >= 80).length,
|
highScore: marketItems.filter(i => i.pounceScore >= 80).length,
|
||||||
avgScore: marketItems.length > 0
|
endingSoon: marketItems.filter(i => {
|
||||||
? Math.round(marketItems.reduce((s, i) => s + i.pounceScore, 0) / marketItems.length) : 0,
|
const seconds = parseTimeToSeconds(i.timeLeft)
|
||||||
|
return seconds < 3600 // Less than 1 hour
|
||||||
|
}).length,
|
||||||
}), [marketItems])
|
}), [marketItems])
|
||||||
|
|
||||||
const formatPrice = (p: number) => p >= 1000 ? `$${(p / 1000).toFixed(1)}k` : `$${p.toLocaleString()}`
|
// Format currency
|
||||||
|
const formatPrice = (price: number) => {
|
||||||
|
if (price >= 1000000) return `$${(price / 1000000).toFixed(1)}M`
|
||||||
|
if (price >= 1000) return `$${(price / 1000).toFixed(1)}k`
|
||||||
|
return `$${price.toLocaleString()}`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TerminalLayout title="Market" subtitle="">
|
<TerminalLayout title="Market" subtitle="">
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
|
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
{/* HEADER - New Professional Style */}
|
{/* HEADER - Live Feed Style */}
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
<div className="flex items-center justify-between pb-4 border-b border-zinc-800/50">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
{/* Left: Title with Live Indicator */}
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-8 h-8 bg-emerald-500/10 border border-emerald-500/20 rounded-lg flex items-center justify-center">
|
<div className="flex items-center gap-3">
|
||||||
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
<div className="relative">
|
||||||
|
<Activity className="w-5 h-5 text-emerald-400" />
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-emerald-400 rounded-full animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-white">Live Market Feed</h1>
|
||||||
|
<p className="text-xs text-zinc-500">Updated in real-time</p>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-white tracking-tight">Market Feed</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-zinc-500">
|
|
||||||
{loading ? 'Loading...' : (
|
{/* Quick Stats Pills */}
|
||||||
<>
|
<div className="hidden md:flex items-center gap-2">
|
||||||
<span className="text-zinc-400">{stats.total}</span> domains
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-zinc-800/50 border border-zinc-700/50 rounded-full">
|
||||||
<span className="mx-2 text-zinc-700">•</span>
|
<span className="text-xs text-zinc-400">{stats.total}</span>
|
||||||
<span className="text-emerald-400">{stats.highScore}</span> high-score
|
<span className="text-xs text-zinc-600">listings</span>
|
||||||
<span className="mx-2 text-zinc-700">•</span>
|
</div>
|
||||||
Avg score: <span className="text-zinc-400">{stats.avgScore}</span>
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-500/10 border border-emerald-500/20 rounded-full">
|
||||||
</>
|
<TrendingUp className="w-3 h-3 text-emerald-400" />
|
||||||
|
<span className="text-xs text-emerald-400">{stats.highScore}</span>
|
||||||
|
<span className="text-xs text-emerald-400/60">high score</span>
|
||||||
|
</div>
|
||||||
|
{stats.endingSoon > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-full">
|
||||||
|
<Flame className="w-3 h-3 text-amber-400" />
|
||||||
|
<span className="text-xs text-amber-400">{stats.endingSoon}</span>
|
||||||
|
<span className="text-xs text-amber-400/60">ending soon</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={refreshing}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-zinc-800/50 border border-zinc-700/50 rounded-lg
|
|
||||||
text-xs font-medium text-zinc-400 hover:text-white hover:border-zinc-600 transition-all"
|
|
||||||
>
|
|
||||||
<RefreshCw className={clsx("w-3.5 h-3.5", refreshing && "animate-spin")} />
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Refresh */}
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-zinc-800/50 border border-zinc-700/50 rounded-lg
|
||||||
|
text-sm font-medium text-zinc-400 hover:text-white hover:border-zinc-600 transition-all
|
||||||
|
disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||||
|
<span className="hidden sm:inline">Refresh</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
{/* FILTER BAR */}
|
{/* FILTER BAR */}
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
<div className="flex flex-wrap items-center gap-2 py-3 px-4 bg-zinc-900/30 border border-zinc-800/50 rounded-lg">
|
<div className="p-4 bg-zinc-900/50 border border-zinc-800 rounded-xl">
|
||||||
<Filter className="w-3.5 h-3.5 text-zinc-600" />
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-zinc-500 mr-2">
|
||||||
<ToggleButton active={hideSpam} onClick={() => setHideSpam(!hideSpam)}>
|
<Filter className="w-4 h-4" />
|
||||||
<Sparkles className="w-3 h-3" />
|
<span className="text-sm font-medium hidden sm:inline">Filters</span>
|
||||||
Hide Spam
|
</div>
|
||||||
</ToggleButton>
|
|
||||||
|
<ToggleButton active={hideSpam} onClick={() => setHideSpam(!hideSpam)}>
|
||||||
<ToggleButton active={pounceOnly} onClick={() => setPounceOnly(!pounceOnly)}>
|
Hide Spam
|
||||||
<Diamond className="w-3 h-3" />
|
</ToggleButton>
|
||||||
Pounce Only
|
|
||||||
</ToggleButton>
|
<ToggleButton active={pounceOnly} onClick={() => setPounceOnly(!pounceOnly)}>
|
||||||
|
<Diamond className="w-3.5 h-3.5" />
|
||||||
<div className="w-px h-5 bg-zinc-800 mx-1" />
|
Pounce Only
|
||||||
|
</ToggleButton>
|
||||||
<Dropdown value={selectedTld} onChange={setSelectedTld} options={TLD_OPTIONS} />
|
|
||||||
<Dropdown value={selectedPrice} onChange={setSelectedPrice} options={PRICE_OPTIONS} />
|
<div className="w-px h-8 bg-zinc-700 hidden sm:block" />
|
||||||
|
|
||||||
<div className="flex-1" />
|
<DropdownSelect
|
||||||
|
value={selectedTld}
|
||||||
<input
|
onChange={setSelectedTld}
|
||||||
type="text"
|
options={TLD_OPTIONS}
|
||||||
value={searchQuery}
|
/>
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Search..."
|
<DropdownSelect
|
||||||
className="w-40 px-3 py-1.5 bg-zinc-800/30 border border-zinc-700/50 rounded
|
value={selectedPrice}
|
||||||
text-xs text-zinc-300 placeholder:text-zinc-600
|
onChange={setSelectedPrice}
|
||||||
focus:outline-none focus:border-emerald-500/50"
|
options={PRICE_OPTIONS}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search domains..."
|
||||||
|
className="w-full px-4 py-2 bg-zinc-800/50 border border-zinc-700/50 rounded-lg
|
||||||
|
text-sm text-zinc-300 placeholder:text-zinc-600
|
||||||
|
focus:outline-none focus:border-emerald-500/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
{/* TABLE */}
|
{/* MARKET TABLE */}
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
<div className="bg-zinc-900/20 border border-zinc-800/50 rounded-lg overflow-hidden">
|
<div className="bg-zinc-900/30 border border-zinc-800 rounded-xl overflow-hidden">
|
||||||
|
|
||||||
{/* Header Row */}
|
{/* Table Header - Sortable */}
|
||||||
<div className="grid grid-cols-12 gap-3 px-4 py-3 bg-zinc-900/50 border-b border-zinc-800/50">
|
<div className="grid grid-cols-12 gap-4 px-6 py-4 bg-zinc-900/50 border-b border-zinc-800">
|
||||||
<div className="col-span-4">
|
<div className="col-span-4">
|
||||||
<SortHeader label="Domain" field="domain" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} />
|
<SortableHeader
|
||||||
|
label="Domain"
|
||||||
|
field="domain"
|
||||||
|
currentSort={sortField}
|
||||||
|
currentDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1 hidden lg:block">
|
<div className="col-span-1 hidden lg:block">
|
||||||
<SortHeader label="Score" field="score" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
|
<SortableHeader
|
||||||
|
label="Score"
|
||||||
|
field="score"
|
||||||
|
currentSort={sortField}
|
||||||
|
currentDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<SortHeader label="Price" field="price" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="right" />
|
<SortableHeader
|
||||||
|
label="Price"
|
||||||
|
field="price"
|
||||||
|
currentSort={sortField}
|
||||||
|
currentDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 hidden md:block">
|
<div className="col-span-2 hidden md:block">
|
||||||
<SortHeader label="Time" field="time" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
|
<SortableHeader
|
||||||
|
label="Time Left"
|
||||||
|
field="time"
|
||||||
|
currentSort={sortField}
|
||||||
|
currentDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1 hidden lg:block">
|
<div className="col-span-1 hidden lg:block">
|
||||||
<SortHeader label="Source" field="source" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
|
<SortableHeader
|
||||||
|
label="Source"
|
||||||
|
field="source"
|
||||||
|
currentSort={sortField}
|
||||||
|
currentDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 text-right">
|
<div className="col-span-2 text-right">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">Action</span>
|
<span className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Action</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Table Body */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="w-5 h-5 text-emerald-500 animate-spin" />
|
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : marketItems.length === 0 ? (
|
) : marketItems.length === 0 ? (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-20">
|
||||||
<BarChart3 className="w-10 h-10 text-zinc-700 mx-auto mb-3" />
|
<TrendingUp className="w-12 h-12 text-zinc-700 mx-auto mb-4" />
|
||||||
<p className="text-sm text-zinc-500">No domains match your filters</p>
|
<p className="text-zinc-500 font-medium">No domains match your filters</p>
|
||||||
|
<p className="text-zinc-600 text-sm mt-1">Try adjusting your filter settings</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-zinc-800/30">
|
<div className="divide-y divide-zinc-800/50">
|
||||||
{marketItems.map((item) => (
|
{marketItems.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"grid grid-cols-12 gap-3 px-4 py-3 items-center transition-colors",
|
"grid grid-cols-12 gap-4 px-6 py-4 items-center transition-colors",
|
||||||
item.isPounce ? "bg-emerald-500/[0.02] hover:bg-emerald-500/[0.05]" : "hover:bg-zinc-800/20"
|
item.isPounce
|
||||||
|
? "bg-emerald-500/[0.03] hover:bg-emerald-500/[0.06]"
|
||||||
|
: "hover:bg-zinc-800/30"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Domain */}
|
{/* Domain */}
|
||||||
<div className="col-span-4">
|
<div className="col-span-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
{item.isPounce && <Diamond className="w-3.5 h-3.5 text-emerald-400 flex-shrink-0" />}
|
{item.isPounce && (
|
||||||
<span className="font-mono text-sm font-semibold text-white truncate">{item.domain}</span>
|
<Diamond className="w-4 h-4 text-emerald-400 flex-shrink-0" />
|
||||||
{item.verified && (
|
|
||||||
<span className="text-[10px] bg-emerald-500/20 text-emerald-400 px-1 rounded">✓</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
<div>
|
||||||
{/* Mobile info */}
|
<span className="font-mono font-semibold text-white">{item.domain}</span>
|
||||||
<div className="flex items-center gap-2 mt-1 lg:hidden">
|
{item.verified && (
|
||||||
<ScoreBadge score={item.pounceScore} />
|
<span className="ml-2 text-xs bg-emerald-500/20 text-emerald-400 px-1.5 py-0.5 rounded">
|
||||||
<SourceBadge source={item.source} isPounce={item.isPounce} />
|
✓ Verified
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Mobile: Show score inline */}
|
||||||
|
<div className="flex items-center gap-2 mt-1 lg:hidden">
|
||||||
|
<ScoreBadge score={item.pounceScore} />
|
||||||
|
<SourceBadge source={item.source} isPounce={item.isPounce} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Score */}
|
{/* Pounce Score */}
|
||||||
<div className="col-span-1 hidden lg:flex justify-center">
|
<div className="col-span-1 hidden lg:flex justify-center">
|
||||||
<ScoreBadge score={item.pounceScore} />
|
<ScoreBadge score={item.pounceScore} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price */}
|
{/* Price / Bid */}
|
||||||
<div className="col-span-2 text-right">
|
<div className="col-span-2 text-right">
|
||||||
<span className="font-mono text-sm font-semibold text-white">{formatPrice(item.price)}</span>
|
<span className="font-semibold text-white font-mono">
|
||||||
{item.priceType === 'bid' && <span className="text-zinc-600 text-[10px] ml-1">bid</span>}
|
{formatPrice(item.price)}
|
||||||
|
</span>
|
||||||
|
{item.priceType === 'bid' && (
|
||||||
|
<span className="text-zinc-500 text-xs ml-1">(bid)</span>
|
||||||
|
)}
|
||||||
{item.numBids && item.numBids > 0 && (
|
{item.numBids && item.numBids > 0 && (
|
||||||
<p className="text-[10px] text-zinc-600">{item.numBids} bids</p>
|
<p className="text-xs text-zinc-500 mt-0.5">{item.numBids} bids</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status / Time */}
|
||||||
<div className="col-span-2 hidden md:flex justify-center">
|
<div className="col-span-2 hidden md:flex justify-center">
|
||||||
<StatusBadge status={item.status} timeLeft={item.timeLeft} />
|
<StatusBadge status={item.status} timeLeft={item.timeLeft} />
|
||||||
</div>
|
</div>
|
||||||
@ -598,38 +724,42 @@ export default function MarketPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="col-span-2 flex items-center gap-1.5 justify-end">
|
<div className="col-span-2 flex items-center gap-2 justify-end">
|
||||||
|
{/* Track Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTrack(item.domain)}
|
onClick={() => handleTrack(item.domain)}
|
||||||
disabled={trackedDomains.has(item.domain) || trackingInProgress === item.domain}
|
disabled={trackedDomains.has(item.domain) || trackingInProgress === item.domain}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-1.5 rounded transition-all",
|
"p-2 rounded-lg transition-all",
|
||||||
trackedDomains.has(item.domain)
|
trackedDomains.has(item.domain)
|
||||||
? "bg-emerald-500/20 text-emerald-400"
|
? "bg-emerald-500/20 text-emerald-400"
|
||||||
: "bg-zinc-800/50 text-zinc-500 hover:text-white"
|
: "bg-zinc-800 text-zinc-400 hover:text-white hover:bg-zinc-700"
|
||||||
)}
|
)}
|
||||||
|
title={trackedDomains.has(item.domain) ? 'Tracked' : 'Add to Watchlist'}
|
||||||
>
|
>
|
||||||
{trackingInProgress === item.domain ? (
|
{trackingInProgress === item.domain ? (
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
) : trackedDomains.has(item.domain) ? (
|
) : trackedDomains.has(item.domain) ? (
|
||||||
<Check className="w-3.5 h-3.5" />
|
<Check className="w-4 h-4" />
|
||||||
) : (
|
) : (
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
<a
|
<a
|
||||||
href={item.affiliateUrl || '#'}
|
href={item.affiliateUrl || '#'}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"inline-flex items-center gap-1 px-3 py-1.5 rounded text-xs font-semibold transition-all",
|
"inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-all",
|
||||||
item.isPounce
|
item.isPounce
|
||||||
? "bg-emerald-500 text-black hover:bg-emerald-400"
|
? "bg-emerald-500 text-black hover:bg-emerald-400"
|
||||||
: "bg-white text-black hover:bg-zinc-200"
|
: "bg-white text-black hover:bg-zinc-200"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.isPounce ? 'Buy' : 'Bid'}
|
{item.isPounce ? 'Buy' : 'Bid'}
|
||||||
<ExternalLink className="w-3 h-3" />
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -638,11 +768,19 @@ export default function MarketPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* ================================================================ */}
|
||||||
<div className="flex items-center justify-between text-[11px] text-zinc-600 px-1">
|
{/* FOOTER INFO */}
|
||||||
<span>{marketItems.length} of {auctions.length} listings</span>
|
{/* ================================================================ */}
|
||||||
<span>GoDaddy • Sedo • NameJet • DropCatch</span>
|
<div className="flex items-center justify-between text-xs text-zinc-600">
|
||||||
|
<span>
|
||||||
|
Showing {marketItems.length} of {auctions.length} total listings
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
Data from GoDaddy, Sedo, NameJet, DropCatch
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</TerminalLayout>
|
</TerminalLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -225,7 +225,7 @@ export default function RadarPage() {
|
|||||||
<Link href="/terminal/watchlist" className="group">
|
<Link href="/terminal/watchlist" className="group">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Watching"
|
title="Watching"
|
||||||
value={totalDomains}
|
value={totalDomains}
|
||||||
subtitle={availableDomains.length > 0 ? `${availableDomains.length} alerts` : undefined}
|
subtitle={availableDomains.length > 0 ? `${availableDomains.length} alerts` : undefined}
|
||||||
icon={Eye}
|
icon={Eye}
|
||||||
accent={availableDomains.length > 0}
|
accent={availableDomains.length > 0}
|
||||||
|
|||||||
Reference in New Issue
Block a user