feat: Complete mobile redesign for Acquire page - terminal 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

This commit is contained in:
2025-12-13 17:54:28 +01:00
parent 26daad68cf
commit 356db5afee
7 changed files with 784 additions and 406 deletions

View File

@ -516,10 +516,41 @@ async def create_listing(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Create a new domain listing.""" """
Create a new domain listing.
SECURITY: Domain must be in user's portfolio before listing for sale.
DNS verification happens in the verification step (separate endpoint).
"""
from app.models.portfolio import PortfolioDomain
domain_lower = data.domain.lower()
# SECURITY CHECK: Domain must be in user's portfolio
portfolio_result = await db.execute(
select(PortfolioDomain).where(
PortfolioDomain.domain == domain_lower,
PortfolioDomain.user_id == current_user.id,
)
)
portfolio_domain = portfolio_result.scalar_one_or_none()
if not portfolio_domain:
raise HTTPException(
status_code=403,
detail="Domain must be in your portfolio before listing for sale. Add it to your portfolio first.",
)
# Check if domain is sold
if portfolio_domain.is_sold:
raise HTTPException(
status_code=400,
detail="Cannot list a sold domain for sale.",
)
# Check if domain is already listed # Check if domain is already listed
existing = await db.execute( existing = await db.execute(
select(DomainListing).where(DomainListing.domain == data.domain.lower()) select(DomainListing).where(DomainListing.domain == domain_lower)
) )
if existing.scalar_one_or_none(): if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="This domain is already listed") raise HTTPException(status_code=400, detail="This domain is already listed")
@ -550,7 +581,7 @@ async def create_listing(
) )
# Generate slug # Generate slug
slug = _generate_slug(data.domain) slug = _generate_slug(domain_lower)
# Check slug uniqueness # Check slug uniqueness
slug_check = await db.execute( slug_check = await db.execute(
@ -561,7 +592,7 @@ async def create_listing(
# Get valuation # Get valuation
try: try:
valuation = await valuation_service.estimate_value(data.domain, db, save_result=False) valuation = await valuation_service.estimate_value(domain_lower, db, save_result=False)
pounce_score = min(100, int(valuation.get("score", 50))) pounce_score = min(100, int(valuation.get("score", 50)))
estimated_value = valuation.get("value", 0) # Fixed: was 'estimated_value', service returns 'value' estimated_value = valuation.get("value", 0) # Fixed: was 'estimated_value', service returns 'value'
except Exception: except Exception:
@ -571,7 +602,7 @@ async def create_listing(
# Create listing # Create listing
listing = DomainListing( listing = DomainListing(
user_id=current_user.id, user_id=current_user.id,
domain=data.domain.lower(), domain=domain_lower,
slug=slug, slug=slug,
title=data.title, title=data.title,
description=data.description, description=data.description,

View File

@ -1,10 +1,12 @@
"""Portfolio API routes.""" """Portfolio API routes."""
import secrets
from datetime import datetime from datetime import datetime
from typing import Optional, List from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select, func, and_ from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import dns.resolver
from app.database import get_db from app.database import get_db
from app.api.deps import get_current_user from app.api.deps import get_current_user
@ -71,6 +73,11 @@ class PortfolioDomainResponse(BaseModel):
notes: Optional[str] notes: Optional[str]
tags: Optional[str] tags: Optional[str]
roi: Optional[float] roi: Optional[float]
# DNS Verification fields
is_dns_verified: bool = False
verification_status: str = "unverified"
verification_code: Optional[str] = None
verified_at: Optional[datetime] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@ -78,6 +85,25 @@ class PortfolioDomainResponse(BaseModel):
from_attributes = True from_attributes = True
class DNSVerificationStartResponse(BaseModel):
"""Response when starting DNS verification."""
domain_id: int
domain: str
verification_code: str
dns_record_type: str
dns_record_name: str
dns_record_value: str
instructions: str
status: str
class DNSVerificationCheckResponse(BaseModel):
"""Response when checking DNS verification."""
verified: bool
status: str
message: str
class PortfolioSummary(BaseModel): class PortfolioSummary(BaseModel):
"""Summary of user's portfolio.""" """Summary of user's portfolio."""
total_domains: int total_domains: int
@ -204,6 +230,10 @@ async def get_portfolio(
notes=d.notes, notes=d.notes,
tags=d.tags, tags=d.tags,
roi=d.roi, roi=d.roi,
is_dns_verified=d.is_dns_verified,
verification_status=d.verification_status,
verification_code=d.verification_code,
verified_at=d.verified_at,
created_at=d.created_at, created_at=d.created_at,
updated_at=d.updated_at, updated_at=d.updated_at,
) )
@ -351,6 +381,10 @@ async def add_portfolio_domain(
notes=domain.notes, notes=domain.notes,
tags=domain.tags, tags=domain.tags,
roi=domain.roi, roi=domain.roi,
is_dns_verified=domain.is_dns_verified,
verification_status=domain.verification_status,
verification_code=domain.verification_code,
verified_at=domain.verified_at,
created_at=domain.created_at, created_at=domain.created_at,
updated_at=domain.updated_at, updated_at=domain.updated_at,
) )
@ -398,6 +432,10 @@ async def get_portfolio_domain(
notes=domain.notes, notes=domain.notes,
tags=domain.tags, tags=domain.tags,
roi=domain.roi, roi=domain.roi,
is_dns_verified=domain.is_dns_verified,
verification_status=domain.verification_status,
verification_code=domain.verification_code,
verified_at=domain.verified_at,
created_at=domain.created_at, created_at=domain.created_at,
updated_at=domain.updated_at, updated_at=domain.updated_at,
) )
@ -454,6 +492,10 @@ async def update_portfolio_domain(
notes=domain.notes, notes=domain.notes,
tags=domain.tags, tags=domain.tags,
roi=domain.roi, roi=domain.roi,
is_dns_verified=domain.is_dns_verified,
verification_status=domain.verification_status,
verification_code=domain.verification_code,
verified_at=domain.verified_at,
created_at=domain.created_at, created_at=domain.created_at,
updated_at=domain.updated_at, updated_at=domain.updated_at,
) )
@ -510,6 +552,10 @@ async def mark_domain_sold(
notes=domain.notes, notes=domain.notes,
tags=domain.tags, tags=domain.tags,
roi=domain.roi, roi=domain.roi,
is_dns_verified=domain.is_dns_verified,
verification_status=domain.verification_status,
verification_code=domain.verification_code,
verified_at=domain.verified_at,
created_at=domain.created_at, created_at=domain.created_at,
updated_at=domain.updated_at, updated_at=domain.updated_at,
) )
@ -593,6 +639,10 @@ async def refresh_domain_value(
notes=domain.notes, notes=domain.notes,
tags=domain.tags, tags=domain.tags,
roi=domain.roi, roi=domain.roi,
is_dns_verified=domain.is_dns_verified,
verification_status=domain.verification_status,
verification_code=domain.verification_code,
verified_at=domain.verified_at,
created_at=domain.created_at, created_at=domain.created_at,
updated_at=domain.updated_at, updated_at=domain.updated_at,
) )
@ -617,3 +667,219 @@ async def get_domain_valuation(
return ValuationResponse(**valuation) return ValuationResponse(**valuation)
# ============== DNS Verification Endpoints ==============
def _generate_verification_code() -> str:
"""Generate a unique verification code."""
return f"pounce-verify-{secrets.token_hex(8)}"
def _domain_to_response(domain: PortfolioDomain) -> PortfolioDomainResponse:
"""Convert PortfolioDomain to response schema."""
return PortfolioDomainResponse(
id=domain.id,
domain=domain.domain,
purchase_date=domain.purchase_date,
purchase_price=domain.purchase_price,
purchase_registrar=domain.purchase_registrar,
registrar=domain.registrar,
renewal_date=domain.renewal_date,
renewal_cost=domain.renewal_cost,
auto_renew=domain.auto_renew,
estimated_value=domain.estimated_value,
value_updated_at=domain.value_updated_at,
is_sold=domain.is_sold,
sale_date=domain.sale_date,
sale_price=domain.sale_price,
status=domain.status,
notes=domain.notes,
tags=domain.tags,
roi=domain.roi,
is_dns_verified=domain.is_dns_verified,
verification_status=domain.verification_status,
verification_code=domain.verification_code,
verified_at=domain.verified_at,
created_at=domain.created_at,
updated_at=domain.updated_at,
)
@router.post("/{domain_id}/verify-dns", response_model=DNSVerificationStartResponse)
async def start_dns_verification(
domain_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Start DNS verification for a portfolio domain.
Returns a verification code that must be added as a TXT record.
"""
result = await db.execute(
select(PortfolioDomain).where(
and_(
PortfolioDomain.id == domain_id,
PortfolioDomain.user_id == current_user.id,
)
)
)
domain = result.scalar_one_or_none()
if not domain:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found in portfolio",
)
if domain.is_dns_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Domain is already verified",
)
# Generate or reuse existing verification code
if not domain.verification_code:
domain.verification_code = _generate_verification_code()
domain.verification_status = "pending"
domain.verification_started_at = datetime.utcnow()
await db.commit()
await db.refresh(domain)
return DNSVerificationStartResponse(
domain_id=domain.id,
domain=domain.domain,
verification_code=domain.verification_code,
dns_record_type="TXT",
dns_record_name=f"_pounce.{domain.domain}",
dns_record_value=domain.verification_code,
instructions=f"Add a TXT record to your DNS settings:\n\nHost/Name: _pounce\nType: TXT\nValue: {domain.verification_code}\n\nDNS changes can take up to 48 hours to propagate, but usually complete within minutes.",
status=domain.verification_status,
)
@router.get("/{domain_id}/verify-dns/check", response_model=DNSVerificationCheckResponse)
async def check_dns_verification(
domain_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Check if DNS verification is complete.
Looks for the TXT record and verifies it matches the expected code.
"""
result = await db.execute(
select(PortfolioDomain).where(
and_(
PortfolioDomain.id == domain_id,
PortfolioDomain.user_id == current_user.id,
)
)
)
domain = result.scalar_one_or_none()
if not domain:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found in portfolio",
)
if domain.is_dns_verified:
return DNSVerificationCheckResponse(
verified=True,
status="verified",
message="Domain ownership already verified",
)
if not domain.verification_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Verification not started. Call POST /verify-dns first.",
)
# Check DNS TXT record
txt_record_name = f"_pounce.{domain.domain}"
verified = False
try:
resolver = dns.resolver.Resolver()
resolver.timeout = 5
resolver.lifetime = 10
answers = resolver.resolve(txt_record_name, 'TXT')
for rdata in answers:
txt_value = rdata.to_text().strip('"')
if txt_value == domain.verification_code:
verified = True
break
except dns.resolver.NXDOMAIN:
return DNSVerificationCheckResponse(
verified=False,
status="pending",
message=f"TXT record not found. Please add a TXT record at _pounce.{domain.domain}",
)
except dns.resolver.NoAnswer:
return DNSVerificationCheckResponse(
verified=False,
status="pending",
message="TXT record exists but has no value. Check your DNS configuration.",
)
except dns.resolver.Timeout:
return DNSVerificationCheckResponse(
verified=False,
status="pending",
message="DNS query timed out. Please try again.",
)
except Exception as e:
return DNSVerificationCheckResponse(
verified=False,
status="error",
message=f"DNS lookup error: {str(e)}",
)
if verified:
domain.is_dns_verified = True
domain.verification_status = "verified"
domain.verified_at = datetime.utcnow()
await db.commit()
return DNSVerificationCheckResponse(
verified=True,
status="verified",
message="Domain ownership verified successfully! You can now list this domain for sale or activate Yield.",
)
else:
return DNSVerificationCheckResponse(
verified=False,
status="pending",
message=f"TXT record found but value doesn't match. Expected: {domain.verification_code}",
)
@router.get("/verified", response_model=List[PortfolioDomainResponse])
async def get_verified_domains(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Get only DNS-verified portfolio domains.
These domains can be used for Yield or For Sale listings.
"""
result = await db.execute(
select(PortfolioDomain).where(
and_(
PortfolioDomain.user_id == current_user.id,
PortfolioDomain.is_dns_verified == True,
PortfolioDomain.is_sold == False,
)
).order_by(PortfolioDomain.domain.asc())
)
domains = result.scalars().all()
return [_domain_to_response(d) for d in domains]

View File

@ -279,11 +279,43 @@ async def activate_domain_for_yield(
""" """
Activate a domain for yield/intent routing. Activate a domain for yield/intent routing.
SECURITY: Domain must be in user's portfolio AND DNS-verified.
This creates the yield domain record and returns DNS setup instructions. This creates the yield domain record and returns DNS setup instructions.
""" """
from app.models.portfolio import PortfolioDomain
domain = request.domain.lower().strip() domain = request.domain.lower().strip()
# Check if domain already exists # SECURITY CHECK 1: Domain must be in user's portfolio
portfolio_result = await db.execute(
select(PortfolioDomain).where(
PortfolioDomain.domain == domain,
PortfolioDomain.user_id == current_user.id,
)
)
portfolio_domain = portfolio_result.scalar_one_or_none()
if not portfolio_domain:
raise HTTPException(
status_code=403,
detail="Domain must be in your portfolio before activating Yield. Add it to your portfolio first.",
)
# SECURITY CHECK 2: Domain must be DNS-verified
if not portfolio_domain.is_dns_verified:
raise HTTPException(
status_code=403,
detail="Domain must be DNS-verified before activating Yield. Verify ownership in your portfolio first.",
)
# SECURITY CHECK 3: Domain must not be sold
if portfolio_domain.is_sold:
raise HTTPException(
status_code=400,
detail="Cannot activate Yield for a sold domain.",
)
# Check if domain already exists in yield system
existing_result = await db.execute( existing_result = await db.execute(
select(YieldDomain).where(YieldDomain.domain == domain) select(YieldDomain).where(YieldDomain.domain == domain)
) )

View File

@ -45,6 +45,13 @@ class PortfolioDomain(Base):
# Status # Status
status: Mapped[str] = mapped_column(String(50), default="active") # active, expired, sold, parked status: Mapped[str] = mapped_column(String(50), default="active") # active, expired, sold, parked
# DNS Verification (required for Yield and For Sale)
is_dns_verified: Mapped[bool] = mapped_column(Boolean, default=False)
verification_status: Mapped[str] = mapped_column(String(50), default="unverified") # unverified, pending, verified, failed
verification_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
verification_started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Notes # Notes
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
tags: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Comma-separated tags: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Comma-separated

View File

@ -5,7 +5,6 @@ import { useStore } from '@/lib/store'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Header } from '@/components/Header' import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer' import { Footer } from '@/components/Footer'
import { PlatformBadge } from '@/components/PremiumTable'
import { import {
Clock, Clock,
ExternalLink, ExternalLink,
@ -13,22 +12,17 @@ import {
Flame, Flame,
Timer, Timer,
Gavel, Gavel,
DollarSign,
X, X,
Lock, Lock,
TrendingUp, TrendingUp,
ChevronUp, ChevronUp,
ChevronDown, ChevronDown,
ChevronsUpDown,
Sparkles,
Diamond, Diamond,
ShieldCheck, ShieldCheck,
Zap,
Filter, Filter,
Check,
Shield,
ArrowUpRight, ArrowUpRight,
ArrowRight ArrowRight,
Loader2
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import clsx from 'clsx' import clsx from 'clsx'
@ -72,72 +66,30 @@ interface Auction {
} }
type TabType = 'all' | 'ending' | 'hot' type TabType = 'all' | 'ending' | 'hot'
type SortField = 'domain' | 'ending' | 'bid' | 'bids'
type SortDirection = 'asc' | 'desc'
const PLATFORMS = [ const PLATFORMS = [
{ id: 'All', name: 'All Sources' }, { id: 'All', name: 'All' },
{ id: 'GoDaddy', name: 'GoDaddy' }, { id: 'GoDaddy', name: 'GoDaddy' },
{ id: 'Sedo', name: 'Sedo' }, { id: 'Sedo', name: 'Sedo' },
{ id: 'NameJet', name: 'NameJet' }, { id: 'NameJet', name: 'NameJet' },
{ id: 'DropCatch', name: 'DropCatch' }, { id: 'DropCatch', name: 'DropCatch' },
] ]
// Premium TLDs that look professional
const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'de', 'ch', 'net', 'org', 'app', 'dev', 'xyz'] const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'de', 'ch', 'net', 'org', 'app', 'dev', 'xyz']
// Vanity Filter: Only show "beautiful" domains to non-authenticated users
function isVanityDomain(auction: Auction): boolean { function isVanityDomain(auction: Auction): boolean {
const domain = auction.domain const parts = auction.domain.split('.')
const parts = domain.split('.')
if (parts.length < 2) return false if (parts.length < 2) return false
const name = parts[0] const name = parts[0]
const tld = parts.slice(1).join('.').toLowerCase() const tld = parts.slice(1).join('.').toLowerCase()
// Check TLD is premium
if (!PREMIUM_TLDS.includes(tld)) return false if (!PREMIUM_TLDS.includes(tld)) return false
// Check length (max 12 characters for the name)
if (name.length > 12) return false if (name.length > 12) return false
// No hyphens
if (name.includes('-')) return false if (name.includes('-')) return false
// No numbers (unless domain is 4 chars or less - short domains are valuable)
if (name.length > 4 && /\d/.test(name)) return false if (name.length > 4 && /\d/.test(name)) return false
return true return true
} }
// Generate a mock "Deal Score" for display purposes export default function AcquirePage() {
function getDealScore(auction: Auction): number | null {
let score = 50
const name = auction.domain.split('.')[0]
if (name.length <= 4) score += 20
else if (name.length <= 6) score += 10
if (['com', 'io', 'ai'].includes(auction.tld)) score += 15
if (auction.age_years && auction.age_years > 5) score += 10
if (auction.num_bids >= 20) score += 15
else if (auction.num_bids >= 10) score += 10
return Math.min(score, 100)
}
function SortIcon({ field, currentField, direction }: { field: SortField, currentField: SortField, direction: SortDirection }) {
if (field !== currentField) {
return <ChevronsUpDown className="w-3 h-3 text-white/20" />
}
return direction === 'asc'
? <ChevronUp className="w-3 h-3 text-accent" />
: <ChevronDown className="w-3 h-3 text-accent" />
}
export default function MarketPage() {
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore() const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
const [allAuctions, setAllAuctions] = useState<Auction[]>([]) const [allAuctions, setAllAuctions] = useState<Auction[]>([])
@ -146,12 +98,11 @@ export default function MarketPage() {
const [pounceItems, setPounceItems] = useState<MarketItem[]>([]) const [pounceItems, setPounceItems] = useState<MarketItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<TabType>('all') const [activeTab, setActiveTab] = useState<TabType>('all')
const [sortField, setSortField] = useState<SortField>('ending')
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [selectedPlatform, setSelectedPlatform] = useState('All') const [selectedPlatform, setSelectedPlatform] = useState('All')
const [maxBid, setMaxBid] = useState('') const [searchFocused, setSearchFocused] = useState(false)
const [showFilters, setShowFilters] = useState(false)
useEffect(() => { useEffect(() => {
checkAuth() checkAuth()
@ -206,143 +157,291 @@ export default function MarketPage() {
} }
} }
// Apply Vanity Filter for non-authenticated users
const displayAuctions = useMemo(() => { const displayAuctions = useMemo(() => {
const current = getCurrentAuctions() const current = getCurrentAuctions()
if (isAuthenticated) { if (isAuthenticated) return current
return current
}
return current.filter(isVanityDomain) return current.filter(isVanityDomain)
}, [activeTab, allAuctions, endingSoon, hotAuctions, isAuthenticated]) }, [activeTab, allAuctions, endingSoon, hotAuctions, isAuthenticated])
const filteredAuctions = displayAuctions.filter(auction => { const filteredAuctions = displayAuctions.filter(auction => {
if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) { if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) return false
return false if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) return false
}
if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) {
return false
}
if (maxBid && auction.current_bid > parseFloat(maxBid)) {
return false
}
return true return true
}) })
const handleSort = (field: SortField) => { const formatCurrency = (amount: number) => {
if (sortField === field) { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amount)
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDirection('asc')
}
}
const sortedAuctions = [...filteredAuctions].sort((a, b) => {
const modifier = sortDirection === 'asc' ? 1 : -1
switch (sortField) {
case 'domain':
return a.domain.localeCompare(b.domain) * modifier
case 'bid':
return (a.current_bid - b.current_bid) * modifier
case 'bids':
return (a.num_bids - b.num_bids) * modifier
default:
return 0
}
})
const formatCurrency = (amount: number, currency = 'USD') => {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
} }
const getTimeColor = (timeRemaining: string) => { const getTimeColor = (timeRemaining: string) => {
if (timeRemaining.includes('m') && !timeRemaining.includes('h')) return 'text-red-400 font-bold' if (timeRemaining.includes('m') && !timeRemaining.includes('h')) return 'text-red-400'
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 12) return 'text-amber-400 font-bold' if (timeRemaining.includes('h') && parseInt(timeRemaining) < 12) return 'text-amber-400'
return 'text-white/40' return 'text-white/40'
} }
const hotPreview = hotAuctions.slice(0, 4)
if (authLoading) { if (authLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-[#020202]"> <div className="min-h-screen flex items-center justify-center bg-[#020202]">
<div className="w-12 h-12 border-[0.5px] border-white/10 border-t-accent animate-spin rounded-full" /> <Loader2 className="w-8 h-8 text-accent animate-spin" />
</div> </div>
) )
} }
return ( return (
<div className="min-h-screen bg-[#020202] text-white relative overflow-x-hidden selection:bg-accent/30 selection:text-white"> <div className="min-h-screen bg-[#020202] text-white">
{/* Cinematic Background - Architectural & Fine */} {/* Background */}
<div className="fixed inset-0 pointer-events-none z-0"> <div className="fixed inset-0 pointer-events-none z-0">
<div className="absolute inset-0 bg-[url('/noise.png')] opacity-[0.03] mix-blend-overlay" /> <div className="absolute inset-0 bg-[url('/noise.png')] opacity-[0.03] mix-blend-overlay" />
<div <div
className="absolute inset-0 opacity-[0.03]" className="absolute inset-0 opacity-[0.02]"
style={{ style={{
backgroundImage: `linear-gradient(rgba(255,255,255,0.3) 0.5px, transparent 0.5px), linear-gradient(90deg, rgba(255,255,255,0.3) 0.5px, transparent 0.5px)`, backgroundImage: `linear-gradient(rgba(255,255,255,0.3) 0.5px, transparent 0.5px), linear-gradient(90deg, rgba(255,255,255,0.3) 0.5px, transparent 0.5px)`,
backgroundSize: '160px 160px', backgroundSize: '80px 80px',
}} }}
/> />
<div className="absolute top-[-30%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.02] rounded-full blur-[200px]" />
</div> </div>
<Header /> <Header />
<main className="relative pt-20 sm:pt-32 pb-16 sm:pb-24 px-4 sm:px-6 flex-1"> {/* ═══════════════════════════════════════════════════════════════════════ */}
{/* MOBILE HEADER */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<header
className="lg:hidden sticky top-14 z-40 bg-[#020202]/95 backdrop-blur-md border-b border-white/[0.08]"
>
<div className="px-4 py-3">
{/* Title Row */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Acquire</span>
</div>
<div className="flex items-center gap-2 text-[10px] font-mono text-white/40">
<span>{filteredAuctions.length} assets</span>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-2">
<div className="bg-white/[0.02] border border-white/[0.08] p-2">
<div className="text-lg font-bold text-white tabular-nums">{allAuctions.length}</div>
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Total</div>
</div>
<div className="bg-accent/[0.05] border border-accent/20 p-2">
<div className="text-lg font-bold text-accent tabular-nums">{endingSoon.length}</div>
<div className="text-[9px] font-mono text-accent/60 uppercase tracking-wider">Ending</div>
</div>
<div className="bg-orange-500/[0.05] border border-orange-500/20 p-2">
<div className="text-lg font-bold text-orange-400 tabular-nums">{hotAuctions.length}</div>
<div className="text-[9px] font-mono text-orange-400/60 uppercase tracking-wider">Hot</div>
</div>
</div>
</div>
</header>
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* MOBILE SEARCH & FILTERS */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<section className="lg:hidden px-4 py-3 border-b border-white/[0.08]">
{/* Search */}
<div className={clsx(
"relative border-2 transition-all duration-200 mb-3",
searchFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]"
)}>
<div className="flex items-center">
<Search className="w-4 h-4 text-white/30 ml-3" />
<input
type="text"
placeholder="search domains..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
className="flex-1 bg-transparent px-3 py-2.5 text-sm font-mono text-white placeholder:text-white/20 outline-none"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="p-2 text-white/30">
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Tab Filters */}
<div className="flex gap-2 mb-3">
{[
{ id: 'all' as const, label: 'All', icon: Gavel },
{ id: 'ending' as const, label: 'Ending', icon: Timer },
{ id: 'hot' as const, label: 'Hot', icon: Flame },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
"flex-1 py-2 text-[10px] font-mono uppercase tracking-wider border transition-all flex items-center justify-center gap-1.5",
activeTab === tab.id
? "bg-accent text-black border-accent"
: "bg-white/[0.02] border-white/[0.08] text-white/50"
)}
>
<tab.icon className="w-3 h-3" />
{tab.label}
</button>
))}
</div>
{/* Platform Filter Toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className="w-full py-2 flex items-center justify-between text-[10px] font-mono text-white/40 border border-white/[0.08] bg-white/[0.02] px-3"
>
<span>Platform: {selectedPlatform}</span>
<ChevronDown className={clsx("w-3 h-3 transition-transform", showFilters && "rotate-180")} />
</button>
{showFilters && (
<div className="mt-2 grid grid-cols-3 gap-1">
{PLATFORMS.map((p) => (
<button
key={p.id}
onClick={() => { setSelectedPlatform(p.id); setShowFilters(false) }}
className={clsx(
"py-2 text-[9px] font-mono uppercase tracking-wider border transition-all",
selectedPlatform === p.id
? "bg-white text-black border-white"
: "bg-white/[0.02] border-white/[0.08] text-white/50"
)}
>
{p.name}
</button>
))}
</div>
)}
</section>
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* MOBILE AUCTION LIST */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<section className="lg:hidden px-4 py-4 pb-20">
{/* Login Banner */}
{!isAuthenticated && (
<div className="mb-4 p-3 bg-accent/5 border border-accent/20">
<div className="flex items-center gap-3">
<Lock className="w-4 h-4 text-accent shrink-0" />
<div className="flex-1">
<p className="text-xs font-bold text-white">Unlock Full Access</p>
<p className="text-[10px] text-white/50">Valuations & deal scores</p>
</div>
<Link href="/register" className="px-3 py-1.5 bg-accent text-black text-[10px] font-bold uppercase">
Join
</Link>
</div>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
) : filteredAuctions.length === 0 ? (
<div className="text-center py-20 text-white/30 font-mono text-sm">
No assets found
</div>
) : (
<div className="space-y-2">
{filteredAuctions.slice(0, 50).map((auction, i) => (
<a
key={`${auction.domain}-${i}`}
href={auction.affiliate_url}
target="_blank"
rel="noopener noreferrer"
className="block p-3 bg-[#0A0A0A] border border-white/[0.08] active:bg-white/[0.03] transition-all"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-bold text-white font-mono truncate">
{auction.domain}
</div>
<div className="flex items-center gap-2 mt-1 text-[10px] font-mono text-white/30">
<span className="uppercase">{auction.platform}</span>
<span></span>
<span className={getTimeColor(auction.time_remaining)}>
<Clock className="w-3 h-3 inline mr-1" />
{auction.time_remaining}
</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="text-sm font-bold text-accent font-mono">
{formatCurrency(auction.current_bid)}
</div>
{auction.num_bids > 0 && (
<div className="text-[10px] text-white/30 font-mono">
{auction.num_bids} bids
</div>
)}
</div>
</div>
</a>
))}
</div>
)}
</section>
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* DESKTOP LAYOUT */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<main className="hidden lg:block relative pt-32 pb-24 px-6">
<div className="max-w-[1400px] mx-auto"> <div className="max-w-[1400px] mx-auto">
{/* Hero Header - High Tech */} {/* Hero Header */}
<div className="mb-12 sm:mb-16 animate-fade-in text-left"> <div className="mb-12 animate-fade-in">
<div className="flex flex-col lg:flex-row justify-between items-end gap-8 border-b border-white/[0.08] pb-12"> <div className="flex justify-between items-end gap-8 border-b border-white/[0.08] pb-10">
<div> <div>
<span className="text-accent font-mono text-[10px] sm:text-xs uppercase tracking-[0.2em] mb-3 sm:mb-4 block flex items-center gap-2"> <span className="text-accent font-mono text-xs uppercase tracking-[0.2em] mb-4 block flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-accent animate-pulse" /> <div className="w-1.5 h-1.5 bg-accent animate-pulse" />
Live Liquidity Pool Live Liquidity Pool
</span> </span>
<h1 className="font-display text-[2.5rem] sm:text-[4rem] md:text-[5rem] lg:text-[6rem] leading-[0.9] tracking-[-0.03em] text-white"> <h1 className="font-display text-[5rem] leading-[0.9] tracking-[-0.03em] text-white">
Acquire Assets. Acquire Assets.
</h1> </h1>
<p className="mt-5 sm:mt-8 text-sm sm:text-lg lg:text-xl text-white/50 max-w-2xl font-light leading-relaxed"> <p className="mt-6 text-lg text-white/50 max-w-2xl font-light leading-relaxed">
Global liquidity pool. Verified assets only. Global liquidity pool. Verified assets only.
<span className="block mt-2 text-white/80">Aggregated from GoDaddy, Sedo, and Pounce Direct.</span> <span className="block mt-2 text-white/80">Aggregated from GoDaddy, Sedo, and Pounce Direct.</span>
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-12 text-right hidden lg:grid"> <div className="grid grid-cols-3 gap-8 text-right">
<div> <div>
<div className="text-3xl font-display text-white mb-1">{formatCurrency(allAuctions.length, 'USD').replace('$', '')}</div> <div className="text-3xl font-display text-white mb-1">{allAuctions.length}</div>
<div className="text-[10px] uppercase tracking-widest text-white/30 font-mono">Live Opportunities</div> <div className="text-[10px] uppercase tracking-widest text-white/30 font-mono">Live</div>
</div>
<div>
<div className="text-3xl font-display text-accent mb-1">{endingSoon.length}</div>
<div className="text-[10px] uppercase tracking-widest text-accent/60 font-mono">Ending</div>
</div> </div>
<div> <div>
<div className="text-3xl font-display text-white mb-1">{pounceItems.length}</div> <div className="text-3xl font-display text-white mb-1">{pounceItems.length}</div>
<div className="text-[10px] uppercase tracking-widest text-accent font-mono">Direct Listings</div> <div className="text-[10px] uppercase tracking-widest text-white/30 font-mono">Direct</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Login Banner for non-authenticated users */} {/* Login Banner */}
{!isAuthenticated && ( {!isAuthenticated && (
<div className="mb-12 p-1 border border-accent/20 bg-accent/5 max-w-3xl mx-auto animate-fade-in relative group"> <div className="mb-10 p-1 border border-accent/20 bg-accent/5 max-w-3xl">
<div className="absolute -top-px -left-px w-2 h-2 border-t border-l border-accent opacity-50" /> <div className="bg-[#050505] p-6 flex items-center justify-between gap-6">
<div className="absolute -top-px -right-px w-2 h-2 border-t border-r border-accent opacity-50" /> <div className="flex items-center gap-4">
<div className="absolute -bottom-px -left-px w-2 h-2 border-b border-l border-accent opacity-50" /> <div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center text-accent">
<div className="absolute -bottom-px -right-px w-2 h-2 border-b border-r border-accent opacity-50" />
<div className="bg-[#050505] p-6 sm:p-8 flex flex-col sm:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-5">
<div className="w-12 h-12 bg-accent/10 border border-accent/20 flex items-center justify-center text-accent">
<Lock className="w-5 h-5" /> <Lock className="w-5 h-5" />
</div> </div>
<div> <div>
<p className="text-lg font-bold text-white mb-1">Restricted Access</p> <p className="text-base font-bold text-white mb-0.5">Restricted Access</p>
<p className="text-sm font-mono text-white/50"> <p className="text-sm font-mono text-white/50">Sign in to unlock valuations and deal scores.</p>
Sign in to unlock valuations, deal scores, and unfiltered data.
</p>
</div> </div>
</div> </div>
<Link <Link
href="/register" href="/register"
className="shrink-0 px-8 py-3 bg-accent text-black text-xs font-bold uppercase tracking-widest hover:bg-white transition-all" className="shrink-0 px-6 py-3 bg-accent text-black text-xs font-bold uppercase tracking-widest hover:bg-white transition-all"
style={{ clipPath: 'polygon(10px 0, 100% 0, 100% 100%, 0 100%, 0 10px)' }}
> >
Authorize Authorize
</Link> </Link>
@ -350,63 +449,40 @@ export default function MarketPage() {
</div> </div>
)} )}
{/* Pounce Direct Section - Featured */} {/* Featured Direct Listings */}
{pounceItems.length > 0 && ( {pounceItems.length > 0 && (
<div className="mb-20 sm:mb-24 animate-slide-up"> <div className="mb-16">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8 sm:mb-10"> <div className="flex items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2 px-3 py-1 bg-accent/10 border border-accent/20"> <div className="flex items-center gap-2 px-3 py-1 bg-accent/10 border border-accent/20">
<Diamond className="w-4 h-4 text-accent" /> <Diamond className="w-4 h-4 text-accent" />
<span className="text-xs font-bold uppercase tracking-widest text-accent">Direct Listings</span> <span className="text-xs font-bold uppercase tracking-widest text-accent">Direct Listings</span>
</div> </div>
<span className="text-[10px] font-mono text-white/30 hidden sm:inline-block">// 0% COMMISSION // INSTANT SETTLEMENT</span> <span className="text-[10px] font-mono text-white/30">// 0% COMMISSION</span>
</div> </div>
<Link href="/pricing" className="text-xs font-mono text-white/40 hover:text-white transition-colors flex items-center gap-2">
How to list my domains? <ArrowRight className="w-3 h-3" />
</Link>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8"> <div className="grid grid-cols-3 gap-4">
{pounceItems.map((item) => ( {pounceItems.slice(0, 3).map((item) => (
<Link <Link
key={item.id} key={item.id}
href={item.url} href={item.url}
className="group relative border border-white/10 bg-[#050505] hover:border-accent/50 transition-all duration-500 flex flex-col h-full" className="group border border-white/10 bg-[#050505] hover:border-accent/50 transition-all p-6"
> >
<div className="absolute top-0 right-0 w-10 h-10 bg-white/5 border-l border-b border-white/10 flex items-center justify-center group-hover:bg-accent group-hover:text-black transition-colors duration-300"> <div className="flex items-center gap-2 mb-4">
<ArrowUpRight className="w-4 h-4" /> <span className="w-1.5 h-1.5 bg-accent animate-pulse" />
<span className="text-[10px] font-bold uppercase tracking-widest text-white/40">Available</span>
</div> </div>
<h3 className="font-mono text-xl text-white font-medium mb-4 truncate group-hover:text-accent transition-colors">
<div className="p-8 flex flex-col h-full"> {item.domain}
<div className="flex items-center gap-2 mb-6"> </h3>
<span className="w-1.5 h-1.5 bg-accent animate-pulse" /> <div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-white/40">Available Now</span> <span className="font-mono text-lg text-accent">{formatCurrency(item.price)}</span>
</div> {item.verified && (
<span className="flex items-center gap-1 text-[10px] text-accent">
<h3 className="font-mono text-2xl sm:text-3xl text-white font-medium mb-3 truncate group-hover:text-accent transition-colors tracking-tight"> <ShieldCheck className="w-3 h-3" /> Verified
{item.domain} </span>
</h3> )}
<div className="flex items-center gap-3 mb-8">
{item.verified && (
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-accent bg-accent/5 px-2 py-1 border border-accent/10">
<ShieldCheck className="w-3 h-3" /> Verified
</span>
)}
<span className="text-[10px] font-mono text-white/30 px-2 py-1 bg-white/5 border border-white/10">
{isAuthenticated ? `Score: ${item.pounce_score}/100` : 'Score: [LOCKED]'}
</span>
</div>
<div className="mt-auto pt-6 border-t border-white/[0.05] flex items-end justify-between">
<div>
<span className="text-[10px] text-white/30 uppercase tracking-widest block mb-1 font-mono">Buy Price</span>
<span className="font-mono text-xl sm:text-2xl text-white">{formatCurrency(item.price, item.currency)}</span>
</div>
<div className="px-5 py-2.5 bg-white/5 border border-white/10 text-white text-[10px] font-bold uppercase tracking-widest group-hover:bg-accent group-hover:text-black group-hover:border-accent transition-all duration-300">
Acquire
</div>
</div>
</div> </div>
</Link> </Link>
))} ))}
@ -414,233 +490,133 @@ export default function MarketPage() {
</div> </div>
)} )}
{/* Search & Filters - Tech Bar */} {/* Search & Filters Bar */}
<div className="mb-10 animate-slide-up sticky top-20 z-30 backdrop-blur-xl bg-[#020202]/90 border-y border-white/[0.08] py-5 -mx-4 px-4 sm:mx-0 sm:px-0"> <div className="mb-6 sticky top-20 z-30 backdrop-blur-xl bg-[#020202]/90 border-y border-white/[0.08] py-4 -mx-6 px-6">
<div className="flex flex-col lg:flex-row gap-5 justify-between items-center max-w-[1400px] mx-auto"> <div className="flex gap-4 justify-between items-center">
{/* Search */} {/* Search */}
<div className="relative w-full lg:w-[480px] group"> <div className="relative w-[400px] group">
<Search className="absolute left-5 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40 group-focus-within:text-accent transition-colors" /> <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40 group-focus-within:text-accent transition-colors" />
<input <input
type="text" type="text"
placeholder="SEARCH_ASSETS..." placeholder="Search assets..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-14 pr-5 py-3.5 bg-[#0A0A0A] border border-white/10 text-white placeholder:text-white/20 font-mono text-base focus:outline-none focus:border-accent focus:bg-[#0F0F0F] transition-all rounded-none" className="w-full pl-12 pr-4 py-3 bg-[#0A0A0A] border border-white/10 text-white placeholder:text-white/20 font-mono text-sm focus:outline-none focus:border-accent transition-all"
/> />
</div> </div>
{/* Filters */}
<div className="flex gap-2">
<select
value={selectedPlatform}
onChange={(e) => setSelectedPlatform(e.target.value)}
className="appearance-none px-4 py-3 bg-[#0A0A0A] border border-white/10 text-white font-mono text-sm focus:outline-none focus:border-accent cursor-pointer"
>
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
{/* Filters */} <div className="flex border border-white/10 bg-[#0A0A0A]">
<div className="flex flex-wrap gap-4 w-full lg:w-auto items-center"> {[
<div className="relative group"> { id: 'all' as const, label: 'All', icon: Gavel },
<select { id: 'ending' as const, label: 'Ending', icon: Timer },
value={selectedPlatform} { id: 'hot' as const, label: 'Hot', icon: Flame },
onChange={(e) => setSelectedPlatform(e.target.value)} ].map((tab) => (
className="appearance-none pl-5 pr-10 py-3.5 bg-[#0A0A0A] border border-white/10 text-white font-mono text-sm focus:outline-none focus:border-accent cursor-pointer min-w-[180px] hover:border-white/30 transition-colors rounded-none" <button
> key={tab.id}
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)} onClick={() => setActiveTab(tab.id)}
</select> className={clsx(
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40 pointer-events-none group-hover:text-white transition-colors" /> "px-5 py-3 flex items-center gap-2 text-xs font-bold uppercase tracking-widest transition-all border-r border-white/10 last:border-r-0",
</div> activeTab === tab.id
? "bg-white/10 text-white border-b-2 border-accent"
<div className="flex border border-white/10 bg-[#0A0A0A]"> : "text-white/40 hover:text-white border-b-2 border-transparent"
{[ )}
{ id: 'all' as const, label: 'ALL', icon: Gavel }, >
{ id: 'ending' as const, label: 'ENDING', icon: Timer }, <tab.icon className={clsx("w-4 h-4", activeTab === tab.id ? "text-accent" : "")} />
{ id: 'hot' as const, label: 'HOT', icon: Flame }, {tab.label}
].map((tab) => ( </button>
<button ))}
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
"px-6 py-3.5 flex items-center gap-3 text-xs font-bold uppercase tracking-widest transition-all border-r border-white/10 last:border-r-0 hover:bg-white/5",
activeTab === tab.id
? "bg-white/10 text-white border-b-2 border-accent"
: "text-white/40 hover:text-white border-b-2 border-transparent"
)}
>
<tab.icon className={clsx("w-4 h-4", activeTab === tab.id ? "text-accent" : "text-current")} />
{tab.label}
</button>
))}
</div>
</div> </div>
</div>
</div> </div>
</div> </div>
{/* Auctions Table - The Terminal */} {/* Desktop Table */}
<div className="border border-white/[0.08] bg-[#050505] animate-slide-up shadow-2xl"> <div className="border border-white/[0.08] bg-[#050505]">
<div className="overflow-x-auto"> {/* Table Header */}
<table className="w-full"> <div className="grid grid-cols-[1fr_100px_120px_100px_80px] gap-4 px-6 py-4 bg-[#0A0A0A] border-b border-white/[0.08] text-[10px] font-mono text-white/40 uppercase tracking-wider">
<thead> <div>Domain</div>
<tr className="bg-[#0A0A0A] border-b border-white/[0.08]"> <div className="text-center">Platform</div>
<th className="text-left px-8 py-5"> <div className="text-right">Price</div>
<button <div className="text-center">Time</div>
onClick={() => handleSort('domain')} <div></div>
className="flex items-center gap-2 text-xs uppercase tracking-widest text-white/40 font-bold hover:text-white transition-colors group"
>
Asset
<span className="opacity-0 group-hover:opacity-100 transition-opacity"><SortIcon field="domain" currentField={sortField} direction={sortDirection} /></span>
</button>
</th>
<th className="text-left px-8 py-5 hidden lg:table-cell">
<span className="text-xs uppercase tracking-widest text-white/40 font-bold">Source</span>
</th>
<th className="text-right px-8 py-5">
<button
onClick={() => handleSort('bid')}
className="flex items-center gap-2 ml-auto text-xs uppercase tracking-widest text-white/40 font-bold hover:text-white transition-colors group"
>
Strike Price
<span className="opacity-0 group-hover:opacity-100 transition-opacity"><SortIcon field="bid" currentField={sortField} direction={sortDirection} /></span>
</button>
</th>
<th className="text-center px-8 py-5 hidden md:table-cell">
<span className="text-xs uppercase tracking-widest text-white/40 font-bold flex items-center justify-center gap-2">
Valuation
{!isAuthenticated && <Lock className="w-3 h-3" />}
</span>
</th>
<th className="text-right px-8 py-5 hidden md:table-cell">
<button
onClick={() => handleSort('ending')}
className="flex items-center gap-2 ml-auto text-xs uppercase tracking-widest text-white/40 font-bold hover:text-white transition-colors group"
>
Window
<span className="opacity-0 group-hover:opacity-100 transition-opacity"><SortIcon field="ending" currentField={sortField} direction={sortDirection} /></span>
</button>
</th>
<th className="px-8 py-5"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.03]">
{loading ? (
Array.from({ length: 10 }).map((_, idx) => (
<tr key={idx} className="animate-pulse">
<td className="px-8 py-5"><div className="h-5 w-48 bg-white/5 rounded-none" /></td>
<td className="px-8 py-5 hidden lg:table-cell"><div className="h-5 w-24 bg-white/5 rounded-none" /></td>
<td className="px-8 py-5"><div className="h-5 w-20 bg-white/5 rounded-none ml-auto" /></td>
<td className="px-8 py-5 hidden md:table-cell"><div className="h-5 w-24 bg-white/5 rounded-none mx-auto" /></td>
<td className="px-8 py-5 hidden md:table-cell"><div className="h-5 w-20 bg-white/5 rounded-none ml-auto" /></td>
<td className="px-8 py-5"><div className="h-8 w-8 bg-white/5 rounded-none ml-auto" /></td>
</tr>
))
) : sortedAuctions.length === 0 ? (
<tr>
<td colSpan={6} className="px-8 py-20 text-center text-white/30 font-mono text-base">
{searchQuery ? `// NO_ASSETS_FOUND: "${searchQuery}"` : '// NO_DATA_AVAILABLE'}
</td>
</tr>
) : (
sortedAuctions.map((auction) => (
<tr
key={`${auction.domain}-${auction.platform}`}
className="group hover:bg-[#0F0F0F] transition-all duration-200 border-l-2 border-transparent hover:border-accent"
>
<td className="px-8 py-6">
<div>
<a
href={auction.affiliate_url}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-lg font-medium text-white group-hover:text-accent transition-colors block tracking-tight"
>
{auction.domain}
</a>
<div className="flex items-center gap-2 mt-1.5 lg:hidden">
<span className="text-[10px] text-white/30 uppercase tracking-wide font-bold">{auction.platform}</span>
</div>
</div>
</td>
<td className="px-8 py-6 hidden lg:table-cell">
<div className="flex items-center gap-3">
<span className="text-xs font-mono text-white/40 group-hover:text-white/60 transition-colors">{auction.platform}</span>
{auction.platform === 'Pounce' && <Diamond className="w-3 h-3 text-accent animate-pulse" />}
</div>
</td>
<td className="px-8 py-6 text-right">
<div>
<span className="font-mono text-lg font-medium text-white group-hover:text-white transition-colors">
{formatCurrency(auction.current_bid)}
</span>
{auction.buy_now_price && (
<span className="block text-[10px] text-accent font-bold uppercase tracking-wide mt-1">Buy Now</span>
)}
</div>
</td>
{/* Valuation - blurred for non-authenticated */}
<td className="px-8 py-6 text-center hidden md:table-cell">
{isAuthenticated ? (
<span className="font-mono text-base text-white/60 group-hover:text-white/80 transition-colors">
${(auction.current_bid * 1.5).toFixed(0)}
</span>
) : (
<div className="flex justify-center">
<span className="font-mono text-base text-white/20 blur-[6px] select-none bg-white/5 px-3 py-0.5 group-hover:text-white/30 transition-colors">
$X,XXX
</span>
</div>
)}
</td>
<td className="px-8 py-6 text-right hidden md:table-cell">
<span className={clsx("font-mono text-sm tracking-tight", getTimeColor(auction.time_remaining))}>
{auction.time_remaining}
</span>
</td>
<td className="px-8 py-6 text-right">
<a
href={auction.affiliate_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-10 h-10 border border-white/10 text-white/40 hover:text-black hover:bg-white hover:border-white transition-all duration-300 opacity-0 group-hover:opacity-100 transform translate-x-2 group-hover:translate-x-0"
>
<ArrowUpRight className="w-4 h-4" />
</a>
</td>
</tr>
))
)}
</tbody>
</table>
</div> </div>
{/* Table Body */}
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
) : filteredAuctions.length === 0 ? (
<div className="text-center py-20 text-white/30 font-mono">No assets found</div>
) : (
<div>
{filteredAuctions.map((auction, i) => (
<a
key={`${auction.domain}-${i}`}
href={auction.affiliate_url}
target="_blank"
rel="noopener noreferrer"
className="grid grid-cols-[1fr_100px_120px_100px_80px] gap-4 items-center px-6 py-4 border-b border-white/[0.03] hover:bg-white/[0.02] transition-all group"
>
<div className="font-mono text-base text-white group-hover:text-accent transition-colors truncate">
{auction.domain}
</div>
<div className="text-center text-xs font-mono text-white/40 uppercase">
{auction.platform}
</div>
<div className="text-right font-mono text-base text-accent">
{formatCurrency(auction.current_bid)}
</div>
<div className={clsx("text-center text-xs font-mono", getTimeColor(auction.time_remaining))}>
{auction.time_remaining}
</div>
<div className="flex justify-end">
<div className="w-8 h-8 border border-white/10 flex items-center justify-center text-white/30 group-hover:bg-white group-hover:text-black transition-all">
<ExternalLink className="w-4 h-4" />
</div>
</div>
</a>
))}
</div>
)}
</div> </div>
{/* Stats */} {/* Stats Footer */}
{!loading && ( {!loading && (
<div className="mt-4 flex justify-between px-4 text-[10px] font-mono text-white/30 uppercase tracking-widest border-t border-white/[0.05] pt-4"> <div className="mt-4 flex justify-between text-[10px] font-mono text-white/30 uppercase tracking-widest">
<span>System Status: Online</span> <span>System Status: Online</span>
<span> <span>Assets: {filteredAuctions.length}</span>
{searchQuery
? `Assets Found: ${sortedAuctions.length}`
: `Total Assets: ${allAuctions.length}`
}
</span>
</div> </div>
)} )}
{/* Bottom CTA */} {/* Bottom CTA */}
{!isAuthenticated && ( {!isAuthenticated && (
<div className="mt-20 p-px bg-gradient-to-r from-accent/20 via-white/5 to-accent/20"> <div className="mt-16 p-px bg-gradient-to-r from-accent/20 via-white/5 to-accent/20">
<div className="bg-[#080808] p-10 text-center relative overflow-hidden"> <div className="bg-[#080808] p-10 text-left">
<div className="absolute inset-0 bg-[url('/noise.png')] opacity-[0.05]" /> <div className="inline-flex items-center justify-center w-10 h-10 border border-accent/20 bg-accent/5 text-accent mb-4">
<div className="relative z-10"> <Filter className="w-5 h-5" />
<div className="inline-flex items-center justify-center w-12 h-12 border border-accent/20 bg-accent/5 text-accent mb-6">
<Filter className="w-6 h-6" />
</div>
<h3 className="text-2xl font-display text-white mb-4">Eliminate Noise.</h3>
<p className="text-white/50 mb-8 max-w-lg mx-auto text-lg font-light">
Our 'Trader' plan filters 99% of junk domains automatically.
Stop digging through spam. Start acquiring assets.
</p>
<Link
href="/pricing"
className="inline-flex items-center gap-3 px-8 py-4 bg-accent text-black text-xs font-bold uppercase tracking-widest hover:bg-white transition-all"
style={{ clipPath: 'polygon(10px 0, 100% 0, 100% 100%, 0 100%, 0 10px)' }}
>
Upgrade Intel
<TrendingUp className="w-4 h-4" />
</Link>
</div>
</div> </div>
<h3 className="text-xl font-display text-white mb-2">Eliminate Noise.</h3>
<p className="text-white/50 mb-6 max-w-md text-sm">
Our Trader plan filters 99% of junk domains automatically.
</p>
<Link
href="/pricing"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-black text-xs font-bold uppercase tracking-widest hover:bg-white transition-all"
>
Upgrade <TrendingUp className="w-4 h-4" />
</Link>
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -19,6 +19,8 @@ import {
Menu, Menu,
Settings, Settings,
Shield, Shield,
ShieldCheck,
ShieldAlert,
LogOut, LogOut,
Crown, Crown,
Sparkles, Sparkles,
@ -34,7 +36,9 @@ import {
CheckCircle, CheckCircle,
AlertCircle, AlertCircle,
TrendingDown, TrendingDown,
BarChart3 BarChart3,
Copy,
Check
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link' import Link from 'next/link'
@ -83,6 +87,7 @@ export default function PortfolioPage() {
const [deletingId, setDeletingId] = useState<number | null>(null) const [deletingId, setDeletingId] = useState<number | null>(null)
const [showAddModal, setShowAddModal] = useState(false) const [showAddModal, setShowAddModal] = useState(false)
const [selectedDomain, setSelectedDomain] = useState<PortfolioDomain | null>(null) const [selectedDomain, setSelectedDomain] = useState<PortfolioDomain | null>(null)
const [verifyingDomain, setVerifyingDomain] = useState<PortfolioDomain | null>(null)
const [filter, setFilter] = useState<'all' | 'active' | 'sold'>('all') const [filter, setFilter] = useState<'all' | 'active' | 'sold'>('all')
// Sorting // Sorting
@ -401,13 +406,24 @@ export default function PortfolioPage() {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{!domain.is_sold && canListForSale && ( {!domain.is_sold && (
<Link domain.is_dns_verified ? (
href={`/terminal/listing?domain=${encodeURIComponent(domain.domain)}`} canListForSale && (
className="flex-1 py-2 bg-amber-400/10 border border-amber-400/20 text-amber-400 text-[10px] font-bold uppercase flex items-center justify-center gap-1" <Link
> href={`/terminal/listing?domain=${encodeURIComponent(domain.domain)}`}
<Tag className="w-3 h-3" />Sell className="flex-1 py-2 bg-amber-400/10 border border-amber-400/20 text-amber-400 text-[10px] font-bold uppercase flex items-center justify-center gap-1"
</Link> >
<Tag className="w-3 h-3" />Sell
</Link>
)
) : (
<button
onClick={() => setVerifyingDomain(domain)}
className="flex-1 py-2 bg-blue-400/10 border border-blue-400/20 text-blue-400 text-[10px] font-bold uppercase flex items-center justify-center gap-1"
>
<ShieldAlert className="w-3 h-3" />Verify
</button>
)
)} )}
<button <button
onClick={() => setSelectedDomain(domain)} onClick={() => setSelectedDomain(domain)}
@ -480,13 +496,25 @@ export default function PortfolioPage() {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-1 justify-end opacity-50 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 justify-end opacity-50 group-hover:opacity-100 transition-opacity">
{!domain.is_sold && canListForSale && ( {/* Verification Status & Actions */}
<Link {!domain.is_sold && (
href={`/terminal/listing?domain=${encodeURIComponent(domain.domain)}`} domain.is_dns_verified ? (
className="h-7 px-2 flex items-center gap-1 text-amber-400 text-[9px] font-bold uppercase border border-amber-400/20 bg-amber-400/10 hover:bg-amber-400/20 transition-all" canListForSale && (
> <Link
<Tag className="w-3 h-3" />Sell href={`/terminal/listing?domain=${encodeURIComponent(domain.domain)}`}
</Link> className="h-7 px-2 flex items-center gap-1 text-amber-400 text-[9px] font-bold uppercase border border-amber-400/20 bg-amber-400/10 hover:bg-amber-400/20 transition-all"
>
<Tag className="w-3 h-3" />Sell
</Link>
)
) : (
<button
onClick={() => setVerifyingDomain(domain)}
className="h-7 px-2 flex items-center gap-1 text-blue-400 text-[9px] font-bold uppercase border border-blue-400/20 bg-blue-400/10 hover:bg-blue-400/20 transition-all"
>
<ShieldAlert className="w-3 h-3" />Verify
</button>
)
)} )}
<button <button
onClick={() => setSelectedDomain(domain)} onClick={() => setSelectedDomain(domain)}

View File

@ -620,6 +620,22 @@ class ApiClient {
}) })
} }
// ============== Portfolio DNS Verification ==============
async startPortfolioDnsVerification(id: number) {
return this.request<DNSVerificationStart>(`/portfolio/${id}/verify-dns`, {
method: 'POST',
})
}
async checkPortfolioDnsVerification(id: number) {
return this.request<DNSVerificationCheck>(`/portfolio/${id}/verify-dns/check`)
}
async getVerifiedPortfolioDomains() {
return this.request<PortfolioDomain[]>('/portfolio/verified')
}
async getDomainValuation(domain: string) { async getDomainValuation(domain: string) {
return this.request<DomainValuation>(`/portfolio/valuation/${domain}`) return this.request<DomainValuation>(`/portfolio/valuation/${domain}`)
} }
@ -884,10 +900,32 @@ export interface PortfolioDomain {
notes: string | null notes: string | null
tags: string | null tags: string | null
roi: number | null roi: number | null
// DNS Verification fields
is_dns_verified: boolean
verification_status: string // 'unverified' | 'pending' | 'verified' | 'failed'
verification_code: string | null
verified_at: string | null
created_at: string created_at: string
updated_at: string updated_at: string
} }
export interface DNSVerificationStart {
domain_id: number
domain: string
verification_code: string
dns_record_type: string
dns_record_name: string
dns_record_value: string
instructions: string
status: string
}
export interface DNSVerificationCheck {
verified: boolean
status: string
message: string
}
export interface PortfolioSummary { export interface PortfolioSummary {
total_domains: number total_domains: number
active_domains: number active_domains: number