- RADAR: dashboard → /terminal/radar - MARKET: auctions + marketplace → /terminal/market - INTEL: pricing → /terminal/intel - WATCHLIST: watchlist + portfolio → /terminal/watchlist - LISTING: listings → /terminal/listing All redirects configured for backwards compatibility. Updated sidebar navigation with new module names.
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useMemo, useCallback } from 'react'
|
|
import { api } from '@/lib/api'
|
|
import { TerminalLayout } from '@/components/TerminalLayout'
|
|
import {
|
|
PageContainer,
|
|
StatCard,
|
|
Badge,
|
|
SearchInput,
|
|
FilterBar,
|
|
SelectDropdown,
|
|
ActionButton,
|
|
} from '@/components/PremiumTable'
|
|
import {
|
|
Search,
|
|
Shield,
|
|
Loader2,
|
|
ExternalLink,
|
|
Store,
|
|
Tag,
|
|
DollarSign,
|
|
Filter,
|
|
} from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import clsx from 'clsx'
|
|
|
|
interface Listing {
|
|
domain: string
|
|
slug: string
|
|
title: string | null
|
|
description: string | null
|
|
asking_price: number | null
|
|
currency: string
|
|
price_type: string
|
|
pounce_score: number | null
|
|
estimated_value: number | null
|
|
is_verified: boolean
|
|
allow_offers: boolean
|
|
public_url: string
|
|
seller_verified: boolean
|
|
}
|
|
|
|
type SortOption = 'newest' | 'price_asc' | 'price_desc' | 'score'
|
|
|
|
export default function CommandMarketplacePage() {
|
|
const [listings, setListings] = useState<Listing[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [minPrice, setMinPrice] = useState('')
|
|
const [maxPrice, setMaxPrice] = useState('')
|
|
const [verifiedOnly, setVerifiedOnly] = useState(false)
|
|
const [sortBy, setSortBy] = useState<SortOption>('newest')
|
|
const [showFilters, setShowFilters] = useState(false)
|
|
|
|
const loadListings = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const params = new URLSearchParams()
|
|
params.set('limit', '100')
|
|
if (sortBy === 'price_asc') params.set('sort', 'price_asc')
|
|
if (sortBy === 'price_desc') params.set('sort', 'price_desc')
|
|
if (verifiedOnly) params.set('verified_only', 'true')
|
|
|
|
const data = await api.request<Listing[]>(`/listings?${params.toString()}`)
|
|
setListings(data)
|
|
} catch (err) {
|
|
console.error('Failed to load listings:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [sortBy, verifiedOnly])
|
|
|
|
useEffect(() => {
|
|
loadListings()
|
|
}, [loadListings])
|
|
|
|
const formatPrice = (price: number | null, currency: string) => {
|
|
if (!price) return 'Make Offer'
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency,
|
|
minimumFractionDigits: 0,
|
|
}).format(price)
|
|
}
|
|
|
|
// Memoized filtered and sorted listings
|
|
const sortedListings = useMemo(() => {
|
|
let result = listings.filter(listing => {
|
|
if (searchQuery && !listing.domain.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
|
if (minPrice && listing.asking_price && listing.asking_price < parseFloat(minPrice)) return false
|
|
if (maxPrice && listing.asking_price && listing.asking_price > parseFloat(maxPrice)) return false
|
|
return true
|
|
})
|
|
|
|
return result.sort((a, b) => {
|
|
switch (sortBy) {
|
|
case 'price_asc': return (a.asking_price || 0) - (b.asking_price || 0)
|
|
case 'price_desc': return (b.asking_price || 0) - (a.asking_price || 0)
|
|
case 'score': return (b.pounce_score || 0) - (a.pounce_score || 0)
|
|
default: return 0
|
|
}
|
|
})
|
|
}, [listings, searchQuery, minPrice, maxPrice, sortBy])
|
|
|
|
// Memoized stats
|
|
const stats = useMemo(() => {
|
|
const verifiedCount = listings.filter(l => l.is_verified).length
|
|
const pricesWithValue = listings.filter(l => l.asking_price)
|
|
const avgPrice = pricesWithValue.length > 0
|
|
? pricesWithValue.reduce((sum, l) => sum + (l.asking_price || 0), 0) / pricesWithValue.length
|
|
: 0
|
|
return { verifiedCount, avgPrice }
|
|
}, [listings])
|
|
|
|
return (
|
|
<TerminalLayout
|
|
title="Marketplace"
|
|
subtitle={`${listings.length} premium domains for sale`}
|
|
actions={
|
|
<Link href="/terminal/listing">
|
|
<ActionButton icon={Tag} variant="secondary">My Listings</ActionButton>
|
|
</Link>
|
|
}
|
|
>
|
|
<PageContainer>
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<StatCard title="Total Listings" value={listings.length} icon={Store} />
|
|
<StatCard title="Verified Sellers" value={stats.verifiedCount} icon={Shield} />
|
|
<StatCard
|
|
title="Avg. Price"
|
|
value={stats.avgPrice > 0 ? `$${Math.round(stats.avgPrice).toLocaleString()}` : '—'}
|
|
icon={DollarSign}
|
|
/>
|
|
<StatCard title="Results" value={sortedListings.length} icon={Search} />
|
|
</div>
|
|
|
|
{/* Search & Filters */}
|
|
<div className="p-4 bg-background-secondary/30 border border-border rounded-2xl space-y-4">
|
|
<div className="flex flex-wrap gap-3">
|
|
{/* Search */}
|
|
<div className="relative flex-1 min-w-[200px]">
|
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search domains..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-12 pr-4 py-3 bg-background border border-border rounded-xl
|
|
text-foreground placeholder:text-foreground-subtle
|
|
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
|
/>
|
|
</div>
|
|
|
|
{/* Sort */}
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
|
className="px-4 py-3 bg-background border border-border rounded-xl text-foreground
|
|
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
|
>
|
|
<option value="newest">Newest First</option>
|
|
<option value="price_asc">Price: Low to High</option>
|
|
<option value="price_desc">Price: High to Low</option>
|
|
<option value="score">Pounce Score</option>
|
|
</select>
|
|
|
|
{/* Filter Toggle */}
|
|
<button
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
className={clsx(
|
|
"flex items-center gap-2 px-4 py-3 border rounded-xl transition-all",
|
|
showFilters
|
|
? "bg-accent/10 border-accent/30 text-accent"
|
|
: "bg-background border-border text-foreground-muted hover:text-foreground"
|
|
)}
|
|
>
|
|
<Filter className="w-4 h-4" />
|
|
Filters
|
|
</button>
|
|
</div>
|
|
|
|
{/* Expanded Filters */}
|
|
{showFilters && (
|
|
<div className="flex flex-wrap items-center gap-4 pt-3 border-t border-border/50">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-foreground-muted">Price:</span>
|
|
<input
|
|
type="number"
|
|
placeholder="Min"
|
|
value={minPrice}
|
|
onChange={(e) => setMinPrice(e.target.value)}
|
|
className="w-24 px-3 py-2 bg-background border border-border rounded-lg text-sm text-foreground
|
|
placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
|
/>
|
|
<span className="text-foreground-subtle">—</span>
|
|
<input
|
|
type="number"
|
|
placeholder="Max"
|
|
value={maxPrice}
|
|
onChange={(e) => setMaxPrice(e.target.value)}
|
|
className="w-24 px-3 py-2 bg-background border border-border rounded-lg text-sm text-foreground
|
|
placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
|
/>
|
|
</div>
|
|
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={verifiedOnly}
|
|
onChange={(e) => setVerifiedOnly(e.target.checked)}
|
|
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
|
|
/>
|
|
<span className="text-sm text-foreground">Verified sellers only</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Listings Grid */}
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
|
</div>
|
|
) : sortedListings.length === 0 ? (
|
|
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-2xl">
|
|
<Store className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
|
|
<h2 className="text-xl font-medium text-foreground mb-2">No Domains Found</h2>
|
|
<p className="text-foreground-muted mb-6">
|
|
{searchQuery || minPrice || maxPrice
|
|
? 'Try adjusting your filters'
|
|
: 'No domains are currently listed for sale'}
|
|
</p>
|
|
<Link
|
|
href="/terminal/listing"
|
|
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
|
>
|
|
<Tag className="w-5 h-5" />
|
|
List Your Domain
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{sortedListings.map((listing) => (
|
|
<Link
|
|
key={listing.slug}
|
|
href={`/buy/${listing.slug}`}
|
|
className="group p-5 bg-background-secondary/30 border border-border rounded-2xl
|
|
hover:border-accent/50 hover:bg-background-secondary/50 transition-all duration-300"
|
|
>
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-mono text-lg font-medium text-foreground group-hover:text-accent transition-colors truncate">
|
|
{listing.domain}
|
|
</h3>
|
|
{listing.title && (
|
|
<p className="text-sm text-foreground-muted truncate mt-1">{listing.title}</p>
|
|
)}
|
|
</div>
|
|
{listing.is_verified && (
|
|
<div className="shrink-0 ml-2 w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center" title="Verified Seller">
|
|
<Shield className="w-4 h-4 text-accent" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{listing.description && (
|
|
<p className="text-sm text-foreground-subtle line-clamp-2 mb-4">
|
|
{listing.description}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex items-end justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{listing.pounce_score && (
|
|
<div className="px-2 py-1 bg-accent/10 text-accent rounded text-sm font-medium">
|
|
{listing.pounce_score}
|
|
</div>
|
|
)}
|
|
{listing.allow_offers && (
|
|
<Badge variant="accent">Offers</Badge>
|
|
)}
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-xl font-semibold text-foreground">
|
|
{formatPrice(listing.asking_price, listing.currency)}
|
|
</p>
|
|
{listing.price_type === 'negotiable' && (
|
|
<p className="text-xs text-accent">Negotiable</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</PageContainer>
|
|
</TerminalLayout>
|
|
)
|
|
}
|
|
|