feat: Marketplace command page + SEO disabled state + Pricing update
MARKETPLACE: - Created /command/marketplace for authenticated users - Shows all listings with search, sort, and filters - Grid layout with domain cards, scores, and verification badges - Links to 'My Listings' for quick access to management SIDEBAR SEO JUICE: - Removed 'Tycoon' badge label - Now shows as disabled/grayed out for non-Tycoon users - Crown icon indicates premium feature - Hover tooltip explains feature & upgrade path PRICING PAGE: - Added new features to tier cards: - Scout: 2 domain listings - Trader: 10 listings, 5 Sniper Alerts - Tycoon: 50 listings, unlimited alerts, SEO Juice - Updated comparison table with: - For Sale Listings row - Sniper Alerts row - SEO Juice Detector row
This commit is contained in:
0
frontend/src/app/command/listings/page.tsx
Normal file → Executable file
0
frontend/src/app/command/listings/page.tsx
Normal file → Executable file
311
frontend/src/app/command/marketplace/page.tsx
Normal file
311
frontend/src/app/command/marketplace/page.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import { PageContainer, StatCard, Badge } from '@/components/PremiumTable'
|
||||
import {
|
||||
Search,
|
||||
Shield,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
Store,
|
||||
Tag,
|
||||
DollarSign,
|
||||
Filter,
|
||||
SortAsc,
|
||||
ArrowUpDown,
|
||||
} 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)
|
||||
|
||||
useEffect(() => {
|
||||
loadListings()
|
||||
}, [sortBy, verifiedOnly])
|
||||
|
||||
const loadListings = 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const filteredListings = listings.filter(listing => {
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
if (!listing.domain.toLowerCase().includes(query)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Price filters
|
||||
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
|
||||
})
|
||||
|
||||
// Sort listings
|
||||
const sortedListings = [...filteredListings].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
|
||||
}
|
||||
})
|
||||
|
||||
const verifiedCount = listings.filter(l => l.is_verified).length
|
||||
const avgPrice = listings.length > 0
|
||||
? listings.filter(l => l.asking_price).reduce((sum, l) => sum + (l.asking_price || 0), 0) / listings.filter(l => l.asking_price).length
|
||||
: 0
|
||||
|
||||
return (
|
||||
<CommandCenterLayout
|
||||
title="Marketplace"
|
||||
subtitle={`${listings.length} premium domains for sale`}
|
||||
actions={
|
||||
<Link
|
||||
href="/command/listings"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg
|
||||
hover:bg-accent-hover transition-all"
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
My Listings
|
||||
</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={verifiedCount} icon={Shield} accent />
|
||||
<StatCard
|
||||
title="Avg. Price"
|
||||
value={avgPrice > 0 ? `$${Math.round(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="/command/listings"
|
||||
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-display 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>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@ -23,8 +23,9 @@ const tiers = [
|
||||
{ text: 'Daily availability scans', highlight: false, available: true },
|
||||
{ text: 'Email alerts', highlight: false, available: true },
|
||||
{ text: 'Raw auction feed', highlight: false, available: true, sublabel: 'Unfiltered' },
|
||||
{ text: 'Curated auction list', highlight: false, available: false },
|
||||
{ text: '2 domain listings', highlight: false, available: true, sublabel: 'For Sale' },
|
||||
{ text: 'Deal scores & valuations', highlight: false, available: false },
|
||||
{ text: 'Sniper Alerts', highlight: false, available: false },
|
||||
],
|
||||
cta: 'Start Free',
|
||||
highlighted: false,
|
||||
@ -43,8 +44,9 @@ const tiers = [
|
||||
{ text: 'Hourly scans', highlight: true, available: true, sublabel: '24x faster' },
|
||||
{ text: 'Smart spam filter', highlight: true, available: true, sublabel: 'Curated list' },
|
||||
{ text: 'Deal scores & valuations', highlight: true, available: true },
|
||||
{ text: '10 domain listings', highlight: true, available: true, sublabel: 'For Sale' },
|
||||
{ text: '5 Sniper Alerts', highlight: true, available: true },
|
||||
{ text: 'Portfolio tracking (25)', highlight: true, available: true },
|
||||
{ text: '90-day price history', highlight: false, available: true },
|
||||
{ text: 'Expiry date tracking', highlight: true, available: true },
|
||||
],
|
||||
cta: 'Upgrade to Trader',
|
||||
@ -62,10 +64,11 @@ const tiers = [
|
||||
features: [
|
||||
{ text: '500 domains to track', highlight: true, available: true },
|
||||
{ text: 'Real-time scans', highlight: true, available: true, sublabel: 'Every 10 min' },
|
||||
{ text: 'Priority alerts', highlight: true, available: true },
|
||||
{ text: '50 domain listings', highlight: true, available: true, sublabel: 'For Sale' },
|
||||
{ text: 'Unlimited Sniper Alerts', highlight: true, available: true },
|
||||
{ text: 'SEO Juice Detector', highlight: true, available: true, sublabel: 'Backlinks' },
|
||||
{ text: 'Unlimited portfolio', highlight: true, available: true },
|
||||
{ text: 'Full price history', highlight: true, available: true },
|
||||
{ text: 'Advanced valuation', highlight: true, available: true },
|
||||
{ text: 'API access', highlight: true, available: true, sublabel: 'Coming soon' },
|
||||
],
|
||||
cta: 'Go Tycoon',
|
||||
@ -80,8 +83,10 @@ const comparisonFeatures = [
|
||||
{ name: 'Check Frequency', scout: 'Daily', trader: 'Hourly', tycoon: '10 min' },
|
||||
{ name: 'Auction Feed', scout: 'Raw (unfiltered)', trader: 'Curated (spam-free)', tycoon: 'Curated (spam-free)' },
|
||||
{ name: 'Deal Scores', scout: '—', trader: 'check', tycoon: 'check' },
|
||||
{ name: 'For Sale Listings', scout: '2', trader: '10', tycoon: '50' },
|
||||
{ name: 'Sniper Alerts', scout: '—', trader: '5', tycoon: 'Unlimited' },
|
||||
{ name: 'Portfolio Domains', scout: '—', trader: '25', tycoon: 'Unlimited' },
|
||||
{ name: 'Domain Valuation', scout: '—', trader: 'check', tycoon: 'Advanced' },
|
||||
{ name: 'SEO Juice Detector', scout: '—', trader: '—', tycoon: 'check' },
|
||||
{ name: 'Price History', scout: '—', trader: '90 days', tycoon: 'Unlimited' },
|
||||
{ name: 'Expiry Tracking', scout: '—', trader: 'check', tycoon: 'check' },
|
||||
]
|
||||
|
||||
@ -72,6 +72,8 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
// Count available domains for notification badge
|
||||
const availableCount = domains?.filter(d => d.is_available).length || 0
|
||||
|
||||
const isTycoon = tierName.toLowerCase() === 'tycoon'
|
||||
|
||||
// SECTION 1: Discover - External market data
|
||||
const discoverItems = [
|
||||
{
|
||||
@ -81,7 +83,7 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/buy',
|
||||
href: '/command/marketplace',
|
||||
label: 'Marketplace',
|
||||
icon: Tag,
|
||||
badge: null,
|
||||
@ -95,7 +97,13 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
]
|
||||
|
||||
// SECTION 2: Manage - Your own assets and tools
|
||||
const manageItems = [
|
||||
const manageItems: Array<{
|
||||
href: string
|
||||
label: string
|
||||
icon: any
|
||||
badge: number | null
|
||||
tycoonOnly?: boolean
|
||||
}> = [
|
||||
{
|
||||
href: '/command/dashboard',
|
||||
label: 'Dashboard',
|
||||
@ -130,7 +138,8 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
href: '/command/seo',
|
||||
label: 'SEO Juice',
|
||||
icon: Link2,
|
||||
badge: 'Tycoon',
|
||||
badge: null,
|
||||
tycoonOnly: true,
|
||||
},
|
||||
]
|
||||
|
||||
@ -246,56 +255,67 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
{collapsed && <div className="h-px bg-border/50 mb-3" />}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{manageItems.map((item) => (
|
||||
<Link
|
||||
{manageItems.map((item) => {
|
||||
const isDisabled = item.tycoonOnly && !isTycoon
|
||||
const ItemWrapper = isDisabled ? 'div' : Link
|
||||
|
||||
return (
|
||||
<ItemWrapper
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
{...(!isDisabled && { href: item.href })}
|
||||
onClick={() => !isDisabled && setMobileOpen(false)}
|
||||
className={clsx(
|
||||
"group relative flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-300",
|
||||
isActive(item.href)
|
||||
isDisabled
|
||||
? "opacity-50 cursor-not-allowed border border-transparent"
|
||||
: isActive(item.href)
|
||||
? "bg-gradient-to-r from-accent/20 to-accent/5 text-foreground border border-accent/20 shadow-[0_0_20px_-5px_rgba(16,185,129,0.2)]"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5 border border-transparent"
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
title={
|
||||
isDisabled
|
||||
? "SEO Juice Detector: Analyze backlinks, domain authority & find hidden SEO value. Upgrade to Tycoon to unlock."
|
||||
: collapsed ? item.label : undefined
|
||||
}
|
||||
>
|
||||
{isActive(item.href) && (
|
||||
{!isDisabled && isActive(item.href) && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-accent rounded-r-full shadow-[0_0_10px_rgba(16,185,129,0.5)]" />
|
||||
)}
|
||||
<div className="relative">
|
||||
<item.icon className={clsx(
|
||||
"w-5 h-5 transition-all duration-300",
|
||||
isActive(item.href)
|
||||
isDisabled
|
||||
? "text-foreground-subtle"
|
||||
: isActive(item.href)
|
||||
? "text-accent drop-shadow-[0_0_8px_rgba(16,185,129,0.5)]"
|
||||
: "group-hover:text-foreground"
|
||||
)} />
|
||||
{item.badge && typeof item.badge === 'number' && (
|
||||
{item.badge && typeof item.badge === 'number' && !isDisabled && (
|
||||
<span className="absolute -top-2 -right-2 w-5 h-5 bg-accent text-background
|
||||
text-[10px] font-bold rounded-full flex items-center justify-center
|
||||
shadow-[0_0_10px_rgba(16,185,129,0.4)] animate-pulse">
|
||||
{item.badge > 9 ? '9+' : item.badge}
|
||||
</span>
|
||||
)}
|
||||
{item.badge && typeof item.badge === 'string' && !collapsed && (
|
||||
<span className="absolute -top-1 -right-10 px-1.5 py-0.5 bg-amber-500/20 text-amber-400
|
||||
text-[9px] font-bold rounded uppercase">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className={clsx(
|
||||
"text-sm font-medium transition-colors",
|
||||
isActive(item.href) && "text-foreground"
|
||||
"text-sm font-medium transition-colors flex-1",
|
||||
isDisabled ? "text-foreground-subtle" : isActive(item.href) && "text-foreground"
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{!isActive(item.href) && (
|
||||
{/* Lock icon for disabled items */}
|
||||
{isDisabled && !collapsed && (
|
||||
<Crown className="w-4 h-4 text-amber-400/60" />
|
||||
)}
|
||||
{!isDisabled && !isActive(item.href) && (
|
||||
<div className="absolute inset-0 rounded-xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</ItemWrapper>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
Reference in New Issue
Block a user