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
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:
@ -516,10 +516,41 @@ async def create_listing(
|
||||
current_user: User = Depends(get_current_user),
|
||||
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
|
||||
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():
|
||||
raise HTTPException(status_code=400, detail="This domain is already listed")
|
||||
@ -550,7 +581,7 @@ async def create_listing(
|
||||
)
|
||||
|
||||
# Generate slug
|
||||
slug = _generate_slug(data.domain)
|
||||
slug = _generate_slug(domain_lower)
|
||||
|
||||
# Check slug uniqueness
|
||||
slug_check = await db.execute(
|
||||
@ -561,7 +592,7 @@ async def create_listing(
|
||||
|
||||
# Get valuation
|
||||
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)))
|
||||
estimated_value = valuation.get("value", 0) # Fixed: was 'estimated_value', service returns 'value'
|
||||
except Exception:
|
||||
@ -571,7 +602,7 @@ async def create_listing(
|
||||
# Create listing
|
||||
listing = DomainListing(
|
||||
user_id=current_user.id,
|
||||
domain=data.domain.lower(),
|
||||
domain=domain_lower,
|
||||
slug=slug,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
"""Portfolio API routes."""
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import dns.resolver
|
||||
|
||||
from app.database import get_db
|
||||
from app.api.deps import get_current_user
|
||||
@ -71,6 +73,11 @@ class PortfolioDomainResponse(BaseModel):
|
||||
notes: Optional[str]
|
||||
tags: Optional[str]
|
||||
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
|
||||
updated_at: datetime
|
||||
|
||||
@ -78,6 +85,25 @@ class PortfolioDomainResponse(BaseModel):
|
||||
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):
|
||||
"""Summary of user's portfolio."""
|
||||
total_domains: int
|
||||
@ -204,6 +230,10 @@ async def get_portfolio(
|
||||
notes=d.notes,
|
||||
tags=d.tags,
|
||||
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,
|
||||
updated_at=d.updated_at,
|
||||
)
|
||||
@ -351,6 +381,10 @@ async def add_portfolio_domain(
|
||||
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,
|
||||
)
|
||||
@ -398,6 +432,10 @@ async def get_portfolio_domain(
|
||||
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,
|
||||
)
|
||||
@ -454,6 +492,10 @@ async def update_portfolio_domain(
|
||||
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,
|
||||
)
|
||||
@ -510,6 +552,10 @@ async def mark_domain_sold(
|
||||
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,
|
||||
)
|
||||
@ -593,6 +639,10 @@ async def refresh_domain_value(
|
||||
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,
|
||||
)
|
||||
@ -617,3 +667,219 @@ async def get_domain_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]
|
||||
|
||||
|
||||
@ -279,11 +279,43 @@ async def activate_domain_for_yield(
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
from app.models.portfolio import PortfolioDomain
|
||||
|
||||
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(
|
||||
select(YieldDomain).where(YieldDomain.domain == domain)
|
||||
)
|
||||
|
||||
@ -45,6 +45,13 @@ class PortfolioDomain(Base):
|
||||
# Status
|
||||
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: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
tags: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Comma-separated
|
||||
|
||||
@ -5,7 +5,6 @@ import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { PlatformBadge } from '@/components/PremiumTable'
|
||||
import {
|
||||
Clock,
|
||||
ExternalLink,
|
||||
@ -13,22 +12,17 @@ import {
|
||||
Flame,
|
||||
Timer,
|
||||
Gavel,
|
||||
DollarSign,
|
||||
X,
|
||||
Lock,
|
||||
TrendingUp,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Sparkles,
|
||||
Diamond,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
Filter,
|
||||
Check,
|
||||
Shield,
|
||||
ArrowUpRight,
|
||||
ArrowRight
|
||||
ArrowRight,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
@ -72,72 +66,30 @@ interface Auction {
|
||||
}
|
||||
|
||||
type TabType = 'all' | 'ending' | 'hot'
|
||||
type SortField = 'domain' | 'ending' | 'bid' | 'bids'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'All', name: 'All Sources' },
|
||||
{ id: 'All', name: 'All' },
|
||||
{ id: 'GoDaddy', name: 'GoDaddy' },
|
||||
{ id: 'Sedo', name: 'Sedo' },
|
||||
{ id: 'NameJet', name: 'NameJet' },
|
||||
{ id: 'DropCatch', name: 'DropCatch' },
|
||||
]
|
||||
|
||||
// Premium TLDs that look professional
|
||||
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 {
|
||||
const domain = auction.domain
|
||||
const parts = domain.split('.')
|
||||
const parts = auction.domain.split('.')
|
||||
if (parts.length < 2) return false
|
||||
|
||||
const name = parts[0]
|
||||
const tld = parts.slice(1).join('.').toLowerCase()
|
||||
|
||||
// Check TLD is premium
|
||||
if (!PREMIUM_TLDS.includes(tld)) return false
|
||||
|
||||
// Check length (max 12 characters for the name)
|
||||
if (name.length > 12) return false
|
||||
|
||||
// No hyphens
|
||||
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
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Generate a mock "Deal Score" for display purposes
|
||||
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() {
|
||||
export default function AcquirePage() {
|
||||
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
||||
|
||||
const [allAuctions, setAllAuctions] = useState<Auction[]>([])
|
||||
@ -146,12 +98,11 @@ export default function MarketPage() {
|
||||
const [pounceItems, setPounceItems] = useState<MarketItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all')
|
||||
const [sortField, setSortField] = useState<SortField>('ending')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedPlatform, setSelectedPlatform] = useState('All')
|
||||
const [maxBid, setMaxBid] = useState('')
|
||||
const [searchFocused, setSearchFocused] = useState(false)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
@ -206,143 +157,291 @@ export default function MarketPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Vanity Filter for non-authenticated users
|
||||
const displayAuctions = useMemo(() => {
|
||||
const current = getCurrentAuctions()
|
||||
if (isAuthenticated) {
|
||||
return current
|
||||
}
|
||||
if (isAuthenticated) return current
|
||||
return current.filter(isVanityDomain)
|
||||
}, [activeTab, allAuctions, endingSoon, hotAuctions, isAuthenticated])
|
||||
|
||||
const filteredAuctions = displayAuctions.filter(auction => {
|
||||
if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) {
|
||||
return false
|
||||
}
|
||||
if (maxBid && auction.current_bid > parseFloat(maxBid)) {
|
||||
return false
|
||||
}
|
||||
if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
||||
if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
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 formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amount)
|
||||
}
|
||||
|
||||
const getTimeColor = (timeRemaining: string) => {
|
||||
if (timeRemaining.includes('m') && !timeRemaining.includes('h')) return 'text-red-400 font-bold'
|
||||
if (timeRemaining.includes('h') && parseInt(timeRemaining) < 12) return 'text-amber-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'
|
||||
return 'text-white/40'
|
||||
}
|
||||
|
||||
const hotPreview = hotAuctions.slice(0, 4)
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020202] text-white relative overflow-x-hidden selection:bg-accent/30 selection:text-white">
|
||||
{/* Cinematic Background - Architectural & Fine */}
|
||||
<div className="min-h-screen bg-[#020202] text-white">
|
||||
{/* Background */}
|
||||
<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 opacity-[0.03]"
|
||||
className="absolute inset-0 opacity-[0.02]"
|
||||
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)`,
|
||||
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>
|
||||
|
||||
<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">
|
||||
{/* Hero Header - High Tech */}
|
||||
<div className="mb-12 sm:mb-16 animate-fade-in text-left">
|
||||
<div className="flex flex-col lg:flex-row justify-between items-end gap-8 border-b border-white/[0.08] pb-12">
|
||||
{/* Hero Header */}
|
||||
<div className="mb-12 animate-fade-in">
|
||||
<div className="flex justify-between items-end gap-8 border-b border-white/[0.08] pb-10">
|
||||
<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" />
|
||||
Live Liquidity Pool
|
||||
</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.
|
||||
</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.
|
||||
<span className="block mt-2 text-white/80">Aggregated from GoDaddy, Sedo, and Pounce Direct.</span>
|
||||
</p>
|
||||
</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 className="text-3xl font-display text-white mb-1">{formatCurrency(allAuctions.length, 'USD').replace('$', '')}</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-white/30 font-mono">Live Opportunities</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</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 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>
|
||||
|
||||
{/* Login Banner for non-authenticated users */}
|
||||
{/* Login Banner */}
|
||||
{!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="absolute -top-px -left-px w-2 h-2 border-t border-l border-accent opacity-50" />
|
||||
<div className="absolute -top-px -right-px w-2 h-2 border-t border-r border-accent opacity-50" />
|
||||
<div className="absolute -bottom-px -left-px w-2 h-2 border-b border-l border-accent opacity-50" />
|
||||
<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">
|
||||
<div className="mb-10 p-1 border border-accent/20 bg-accent/5 max-w-3xl">
|
||||
<div className="bg-[#050505] p-6 flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center text-accent">
|
||||
<Lock className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-white mb-1">Restricted Access</p>
|
||||
<p className="text-sm font-mono text-white/50">
|
||||
Sign in to unlock valuations, deal scores, and unfiltered data.
|
||||
</p>
|
||||
<p className="text-base font-bold text-white mb-0.5">Restricted Access</p>
|
||||
<p className="text-sm font-mono text-white/50">Sign in to unlock valuations and deal scores.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
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"
|
||||
style={{ clipPath: 'polygon(10px 0, 100% 0, 100% 100%, 0 100%, 0 10px)' }}
|
||||
className="shrink-0 px-6 py-3 bg-accent text-black text-xs font-bold uppercase tracking-widest hover:bg-white transition-all"
|
||||
>
|
||||
Authorize
|
||||
</Link>
|
||||
@ -350,63 +449,40 @@ export default function MarketPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pounce Direct Section - Featured */}
|
||||
{/* Featured Direct Listings */}
|
||||
{pounceItems.length > 0 && (
|
||||
<div className="mb-20 sm:mb-24 animate-slide-up">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8 sm:mb-10">
|
||||
<div className="mb-16">
|
||||
<div className="flex items-center justify-between gap-4 mb-6">
|
||||
<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">
|
||||
<Diamond className="w-4 h-4 text-accent" />
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-accent">Direct Listings</span>
|
||||
</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>
|
||||
<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 className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
|
||||
{pounceItems.map((item) => (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{pounceItems.slice(0, 3).map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
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">
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
<div className="flex items-center gap-2 mb-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 className="p-8 flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<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 Now</span>
|
||||
</div>
|
||||
|
||||
<h3 className="font-mono text-2xl sm:text-3xl text-white font-medium mb-3 truncate group-hover:text-accent transition-colors tracking-tight">
|
||||
{item.domain}
|
||||
</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>
|
||||
<h3 className="font-mono text-xl text-white font-medium mb-4 truncate group-hover:text-accent transition-colors">
|
||||
{item.domain}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-lg text-accent">{formatCurrency(item.price)}</span>
|
||||
{item.verified && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-accent">
|
||||
<ShieldCheck className="w-3 h-3" /> Verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
@ -414,233 +490,133 @@ export default function MarketPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search & Filters - Tech 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="flex flex-col lg:flex-row gap-5 justify-between items-center max-w-[1400px] mx-auto">
|
||||
{/* Search */}
|
||||
<div className="relative w-full lg:w-[480px] 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" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="SEARCH_ASSETS..."
|
||||
value={searchQuery}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
{/* Search & Filters Bar */}
|
||||
<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 gap-4 justify-between items-center">
|
||||
{/* Search */}
|
||||
<div className="relative w-[400px] group">
|
||||
<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
|
||||
type="text"
|
||||
placeholder="Search assets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
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>
|
||||
|
||||
{/* 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 flex-wrap gap-4 w-full lg:w-auto items-center">
|
||||
<div className="relative group">
|
||||
<select
|
||||
value={selectedPlatform}
|
||||
onChange={(e) => setSelectedPlatform(e.target.value)}
|
||||
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"
|
||||
>
|
||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div className="flex border border-white/10 bg-[#0A0A0A]">
|
||||
{[
|
||||
{ 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(
|
||||
"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 className="flex border border-white/10 bg-[#0A0A0A]">
|
||||
{[
|
||||
{ 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(
|
||||
"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",
|
||||
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" : "")} />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auctions Table - The Terminal */}
|
||||
<div className="border border-white/[0.08] bg-[#050505] animate-slide-up shadow-2xl">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-[#0A0A0A] border-b border-white/[0.08]">
|
||||
<th className="text-left px-8 py-5">
|
||||
<button
|
||||
onClick={() => handleSort('domain')}
|
||||
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>
|
||||
{/* Desktop Table */}
|
||||
<div className="border border-white/[0.08] bg-[#050505]">
|
||||
{/* Table Header */}
|
||||
<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">
|
||||
<div>Domain</div>
|
||||
<div className="text-center">Platform</div>
|
||||
<div className="text-right">Price</div>
|
||||
<div className="text-center">Time</div>
|
||||
<div></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>
|
||||
|
||||
{/* Stats */}
|
||||
{/* Stats Footer */}
|
||||
{!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>
|
||||
{searchQuery
|
||||
? `Assets Found: ${sortedAuctions.length}`
|
||||
: `Total Assets: ${allAuctions.length}`
|
||||
}
|
||||
</span>
|
||||
<span>Assets: {filteredAuctions.length}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom CTA */}
|
||||
{!isAuthenticated && (
|
||||
<div className="mt-20 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="absolute inset-0 bg-[url('/noise.png')] opacity-[0.05]" />
|
||||
<div className="relative z-10">
|
||||
<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 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-left">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 border border-accent/20 bg-accent/5 text-accent mb-4">
|
||||
<Filter className="w-5 h-5" />
|
||||
</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>
|
||||
|
||||
@ -19,6 +19,8 @@ import {
|
||||
Menu,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
LogOut,
|
||||
Crown,
|
||||
Sparkles,
|
||||
@ -34,7 +36,9 @@ import {
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
TrendingDown,
|
||||
BarChart3
|
||||
BarChart3,
|
||||
Copy,
|
||||
Check
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
@ -83,6 +87,7 @@ export default function PortfolioPage() {
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [selectedDomain, setSelectedDomain] = useState<PortfolioDomain | null>(null)
|
||||
const [verifyingDomain, setVerifyingDomain] = useState<PortfolioDomain | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'active' | 'sold'>('all')
|
||||
|
||||
// Sorting
|
||||
@ -401,13 +406,24 @@ export default function PortfolioPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{!domain.is_sold && canListForSale && (
|
||||
<Link
|
||||
href={`/terminal/listing?domain=${encodeURIComponent(domain.domain)}`}
|
||||
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"
|
||||
>
|
||||
<Tag className="w-3 h-3" />Sell
|
||||
</Link>
|
||||
{!domain.is_sold && (
|
||||
domain.is_dns_verified ? (
|
||||
canListForSale && (
|
||||
<Link
|
||||
href={`/terminal/listing?domain=${encodeURIComponent(domain.domain)}`}
|
||||
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"
|
||||
>
|
||||
<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
|
||||
onClick={() => setSelectedDomain(domain)}
|
||||
@ -480,13 +496,25 @@ export default function PortfolioPage() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 justify-end opacity-50 group-hover:opacity-100 transition-opacity">
|
||||
{!domain.is_sold && canListForSale && (
|
||||
<Link
|
||||
href={`/terminal/listing?domain=${encodeURIComponent(domain.domain)}`}
|
||||
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>
|
||||
{/* Verification Status & Actions */}
|
||||
{!domain.is_sold && (
|
||||
domain.is_dns_verified ? (
|
||||
canListForSale && (
|
||||
<Link
|
||||
href={`/terminal/listing?domain=${encodeURIComponent(domain.domain)}`}
|
||||
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
|
||||
onClick={() => setSelectedDomain(domain)}
|
||||
|
||||
@ -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) {
|
||||
return this.request<DomainValuation>(`/portfolio/valuation/${domain}`)
|
||||
}
|
||||
@ -884,10 +900,32 @@ export interface PortfolioDomain {
|
||||
notes: string | null
|
||||
tags: string | 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
|
||||
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 {
|
||||
total_domains: number
|
||||
active_domains: number
|
||||
|
||||
Reference in New Issue
Block a user